Better markdown editor and SegmentedControl

This commit is contained in:
Gregory Schier
2025-01-13 10:46:13 -08:00
parent 84c3987c34
commit 587667fe79
4 changed files with 86 additions and 64 deletions
+26 -60
View File
@@ -1,15 +1,12 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtom } from 'jotai'; import { useRef, useState } from 'react';
import { useRef } from 'react';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor/Editor'; import type { EditorProps } from './core/Editor/Editor';
import { Editor } from './core/Editor/Editor'; import { Editor } from './core/Editor/Editor';
import { HStack, VStack } from './core/Stacks';
import { Prose } from './Prose'; import { Prose } from './Prose';
import { SegmentedControl } from './core/SegmentedControl';
type ViewMode = 'edit' | 'preview'; type ViewMode = 'edit' | 'preview';
@@ -19,27 +16,19 @@ interface Props extends Pick<EditorProps, 'heightMode' | 'stateKey' | 'forceUpda
defaultValue: string; defaultValue: string;
onChange: (value: string) => void; onChange: (value: string) => void;
name: string; name: string;
defaultMode?: ViewMode;
doneButtonLabel?: string;
} }
const viewModeAtom = atom<Record<string, ViewMode>>({});
export function MarkdownEditor({ export function MarkdownEditor({
className, className,
defaultValue, defaultValue,
onChange, onChange,
name, name,
defaultMode = 'preview',
doneButtonLabel = 'Save',
forceUpdateKey, forceUpdateKey,
...editorProps ...editorProps
}: Props) { }: Props) {
const [rawViewMode, setViewMode] = useAtom(viewModeAtom); const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? 'preview' : 'edit');
const [value, setValue] = useStateWithDeps<string>(defaultValue, [forceUpdateKey]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const viewMode = rawViewMode[name] ?? defaultMode;
const editor = ( const editor = (
<Editor <Editor
@@ -48,8 +37,7 @@ export function MarkdownEditor({
className="max-w-2xl max-h-full" className="max-w-2xl max-h-full"
language="markdown" language="markdown"
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={setValue} onChange={onChange}
autoFocus
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
{...editorProps} {...editorProps}
/> />
@@ -61,7 +49,7 @@ export function MarkdownEditor({
) : ( ) : (
<Prose className="max-w-xl overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto"> <Prose className="max-w-xl overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{value} {defaultValue}
</Markdown> </Markdown>
</Prose> </Prose>
); );
@@ -72,53 +60,31 @@ export function MarkdownEditor({
<div <div
ref={containerRef} ref={containerRef}
className={classNames( className={classNames(
'w-full h-full pt-1.5 group rounded-md grid grid-cols-[minmax(0,1fr)_auto] grid-rows-1 gap-x-1.5', 'group/markdown',
'w-full h-full pt-1.5 rounded-md grid grid-cols-[minmax(0,1fr)_auto] grid-rows-1 gap-x-1.5',
className, className,
)} )}
> >
<div className="h-full w-full">{contents}</div> <div className="h-full w-full">{contents}</div>
<VStack <SegmentedControl
space={1} name={name}
className="bg-surface opacity-20 group-hover:opacity-100 transition-opacity transform-gpu" onChange={setViewMode}
> value={viewMode}
{viewMode === 'preview' && ( options={[
<Button {
size="xs" event: { id: 'md_mode', mode: 'preview' },
variant="border" icon: 'eye',
event={{ id: 'md_mode', mode: viewMode }} label: 'Preview mode',
onClick={() => setViewMode((prev) => ({ ...prev, [name]: 'edit' }))} value: 'preview',
> },
Edit {
</Button> event: { id: 'md_mode', mode: 'edit' },
)} icon: 'pencil',
{viewMode === 'edit' && ( label: 'Edit mode',
<HStack space={2}> value: 'edit',
<Button },
size="xs" ]}
event={{ id: 'md_mode', mode: viewMode }} />
color="secondary"
variant="border"
onClick={() => {
setViewMode((prev) => ({ ...prev, [name]: 'preview' }));
}}
>
Cancel
</Button>
<Button
size="xs"
variant="border"
color="primary"
event={{ id: 'md_mode', mode: viewMode }}
onClick={() => {
onChange(value);
setViewMode((prev) => ({ ...prev, [name]: 'preview' }));
}}
>
{doneButtonLabel}
</Button>
</HStack>
)}
</VStack>
</div> </div>
); );
} }
+3 -3
View File
@@ -7,7 +7,7 @@ import { Button } from './Button';
import type { IconProps } from './Icon'; import type { IconProps } from './Icon';
import { Icon } from './Icon'; import { Icon } from './Icon';
type Props = IconProps & export type IconButtonProps = IconProps &
ButtonProps & { ButtonProps & {
showConfirm?: boolean; showConfirm?: boolean;
iconClassName?: string; iconClassName?: string;
@@ -16,7 +16,7 @@ type Props = IconProps &
showBadge?: boolean; showBadge?: boolean;
}; };
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton( export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ {
showConfirm, showConfirm,
icon, icon,
@@ -30,7 +30,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
iconSize, iconSize,
showBadge, showBadge,
...props ...props
}: Props, }: IconButtonProps,
ref, ref,
) { ) {
const [confirmed, setConfirmed] = useTimedBoolean(); const [confirmed, setConfirmed] = useTimedBoolean();
@@ -0,0 +1,56 @@
import { useRef } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import type { IconProps } from './Icon';
import type { IconButtonProps } from './IconButton';
import { IconButton } from './IconButton';
import { HStack } from './Stacks';
interface Props<T extends string> {
options: { value: T; label: string; icon: IconProps['icon']; event?: IconButtonProps['event'] }[];
onChange: (value: T) => void;
value: T;
name: string;
}
export function SegmentedControl<T extends string>({ value, onChange, options, name }: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
return (
<HStack
ref={containerRef}
space={1}
role="group"
dir="ltr"
className="mb-auto bg-surface opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100 transition-opacity transform-gpu"
onKeyDown={(e) => {
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
if (e.key === 'ArrowRight') {
const newIndex = Math.abs((selectedIndex + 1) % options.length);
setSelectedValue(options[newIndex]!.value);
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
child.focus();
} else if (e.key === 'ArrowLeft') {
const newIndex = Math.abs((selectedIndex - 1) % options.length);
setSelectedValue(options[newIndex]!.value);
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
child.focus();
}
}}
>
{options.map((o, i) => (
<IconButton
size="xs"
variant={value === o.value ? 'solid' : 'border'}
color={value === o.value ? 'secondary' : 'default'}
role="radio"
event={{ id: name, value: String(o.value) }}
tabIndex={selectedValue === o.value ? 0 : -1}
key={i}
title={o.label}
icon={o.icon}
onClick={() => onChange(o.value)}
/>
))}
</HStack>
);
}
+1 -1
View File
@@ -44,7 +44,7 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'settings.show': ['CmdCtrl+,'], 'settings.show': ['CmdCtrl+,'],
'sidebar.focus': ['CmdCtrl+b'], 'sidebar.focus': ['CmdCtrl+b'],
'url_bar.focus': ['CmdCtrl+l'], 'url_bar.focus': ['CmdCtrl+l'],
'workspace_settings.show': ['CmdCtrl+Shift+,'], 'workspace_settings.show': ['CmdCtrl+;'],
}; };
const hotkeyLabels: Record<HotkeyAction, string> = { const hotkeyLabels: Record<HotkeyAction, string> = {