Files
llink/js/desktop/src/features/compose/markdown-editor.tsx
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

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)}
/>
);
}