* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Crepe } from '@milkdown/crepe';
|
|
import '@milkdown/crepe/theme/common/style.css';
|
|
import '@milkdown/crepe/theme/frame-dark.css';
|
|
import './markdown-editor.css';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface MarkdownEditorProps {
|
|
/** Initial markdown. The editor owns its content after mount; edits flow out
|
|
* through `onChange`, so this is only read when the editor is (re)created. */
|
|
value: string;
|
|
onChange?: (markdown: string) => void;
|
|
placeholder?: string;
|
|
/** Render the same engine read-only, for displaying a message. */
|
|
readOnly?: boolean;
|
|
autoFocus?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Single markdown engine used for both composing and displaying messages
|
|
* (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full
|
|
* GFM — headings, lists, task lists, tables, code blocks, quotes, links — and
|
|
* read-only mode renders the exact same way, so write and read never drift.
|
|
*/
|
|
export function MarkdownEditor({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
readOnly = false,
|
|
autoFocus = true,
|
|
className,
|
|
}: MarkdownEditorProps) {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
// Keep the latest onChange without forcing the editor to be recreated.
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
|
|
useEffect(() => {
|
|
const root = rootRef.current;
|
|
if (!root) return;
|
|
|
|
let cancelled = false;
|
|
let created: Crepe | null = null;
|
|
|
|
const crepe = new Crepe({
|
|
root,
|
|
defaultValue: value,
|
|
features: {
|
|
[Crepe.Feature.CodeMirror]: true,
|
|
[Crepe.Feature.ListItem]: true,
|
|
[Crepe.Feature.Table]: true,
|
|
[Crepe.Feature.LinkTooltip]: !readOnly,
|
|
[Crepe.Feature.Cursor]: !readOnly,
|
|
[Crepe.Feature.BlockEdit]: !readOnly,
|
|
[Crepe.Feature.Toolbar]: !readOnly,
|
|
[Crepe.Feature.Placeholder]: !readOnly,
|
|
[Crepe.Feature.ImageBlock]: false,
|
|
[Crepe.Feature.Latex]: false,
|
|
[Crepe.Feature.TopBar]: false,
|
|
[Crepe.Feature.AI]: false,
|
|
},
|
|
featureConfigs: {
|
|
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
|
|
},
|
|
});
|
|
|
|
crepe.setReadonly(readOnly);
|
|
|
|
if (!readOnly) {
|
|
crepe.on((listener) => {
|
|
listener.markdownUpdated((_ctx, markdown) => {
|
|
onChangeRef.current?.(markdown);
|
|
});
|
|
});
|
|
}
|
|
|
|
crepe.create().then(() => {
|
|
if (cancelled) {
|
|
crepe.destroy();
|
|
return;
|
|
}
|
|
created = crepe;
|
|
if (autoFocus && !readOnly) {
|
|
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
created?.destroy();
|
|
};
|
|
// `value` is the initial content only; recreate when the mode flips.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [readOnly]);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
|
|
/>
|
|
);
|
|
}
|