mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-01 10:31:41 +02:00
Better markdown editor and SegmentedControl
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user