2026-02-17 12:47:39 +05:30
|
|
|
|
"use client";
|
2026-02-16 00:11:34 +05:30
|
|
|
|
|
2026-02-17 12:47:39 +05:30
|
|
|
|
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
|
2026-03-22 02:42:51 +05:30
|
|
|
|
import { slateToHtml } from "@slate-serializers/html";
|
|
|
|
|
|
import type { AnyPluginConfig, Descendant, Value } from "platejs";
|
2026-02-18 00:26:18 +05:30
|
|
|
|
import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
|
2026-02-20 22:44:56 -08:00
|
|
|
|
import { useEffect, useMemo, useRef } from "react";
|
2026-02-17 12:47:39 +05:30
|
|
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
|
|
import remarkMath from "remark-math";
|
2026-02-20 22:44:56 -08:00
|
|
|
|
import { EditorSaveContext } from "@/components/editor/editor-save-context";
|
2026-02-18 03:49:28 +05:30
|
|
|
|
import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
2026-02-17 12:47:39 +05:30
|
|
|
|
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
2026-02-20 22:44:56 -08:00
|
|
|
|
import { Editor, EditorContainer } from "@/components/ui/editor";
|
2026-02-16 00:11:34 +05:30
|
|
|
|
|
2026-02-18 03:49:28 +05:30
|
|
|
|
export interface PlateEditorProps {
|
2026-02-17 12:47:39 +05:30
|
|
|
|
/** Markdown string to load as initial content */
|
|
|
|
|
|
markdown?: string;
|
2026-03-22 02:42:51 +05:30
|
|
|
|
/** HTML string to load as initial content. Takes precedence over `markdown`. */
|
|
|
|
|
|
html?: string;
|
2026-02-17 12:47:39 +05:30
|
|
|
|
/** Called when the editor content changes, with serialized markdown */
|
|
|
|
|
|
onMarkdownChange?: (markdown: string) => void;
|
2026-03-22 02:42:51 +05:30
|
|
|
|
/** Called when the editor content changes, with serialized HTML. Use with the `html` prop. */
|
|
|
|
|
|
onHtmlChange?: (html: string) => void;
|
2026-02-17 12:47:39 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Force permanent read-only mode (e.g. public/shared view).
|
|
|
|
|
|
* When true, the editor cannot be toggled to editing mode.
|
|
|
|
|
|
* When false (default), the editor starts in viewing mode but
|
|
|
|
|
|
* the user can switch to editing via the mode toolbar button.
|
|
|
|
|
|
*/
|
|
|
|
|
|
readOnly?: boolean;
|
|
|
|
|
|
/** Placeholder text */
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
/** Editor container variant */
|
|
|
|
|
|
variant?: "default" | "demo" | "comment" | "select";
|
|
|
|
|
|
/** Editor text variant */
|
|
|
|
|
|
editorVariant?: "default" | "demo" | "fullWidth" | "none";
|
|
|
|
|
|
/** Additional className for the container */
|
|
|
|
|
|
className?: string;
|
2026-02-18 03:49:28 +05:30
|
|
|
|
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
|
2026-02-17 12:47:39 +05:30
|
|
|
|
onSave?: () => void;
|
|
|
|
|
|
/** Whether there are unsaved changes */
|
|
|
|
|
|
hasUnsavedChanges?: boolean;
|
|
|
|
|
|
/** Whether a save is in progress */
|
|
|
|
|
|
isSaving?: boolean;
|
|
|
|
|
|
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
|
|
|
|
|
|
defaultEditing?: boolean;
|
2026-02-18 03:49:28 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Plugin preset to use. Controls which plugin kits are loaded.
|
|
|
|
|
|
* - "full" – all plugins (toolbars, slash commands, DnD, etc.)
|
|
|
|
|
|
* - "minimal" – core formatting only (no fixed toolbar, slash commands, DnD, block selection)
|
|
|
|
|
|
* - "readonly" – rendering support for all rich content, no editing UI
|
|
|
|
|
|
* @default "full"
|
|
|
|
|
|
*/
|
|
|
|
|
|
preset?: EditorPreset;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Additional plugins to append after the preset plugins.
|
|
|
|
|
|
* Use this to inject feature-specific plugins (e.g. approve/reject blocks)
|
|
|
|
|
|
* without modifying the core editor component.
|
|
|
|
|
|
*/
|
|
|
|
|
|
extraPlugins?: AnyPluginConfig[];
|
2026-02-16 00:11:34 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function PlateEditor({
|
2026-02-17 12:47:39 +05:30
|
|
|
|
markdown,
|
2026-03-22 02:42:51 +05:30
|
|
|
|
html,
|
2026-02-17 12:47:39 +05:30
|
|
|
|
onMarkdownChange,
|
2026-03-22 02:42:51 +05:30
|
|
|
|
onHtmlChange,
|
2026-02-17 12:47:39 +05:30
|
|
|
|
readOnly = false,
|
|
|
|
|
|
placeholder = "Type...",
|
|
|
|
|
|
variant = "default",
|
|
|
|
|
|
editorVariant = "default",
|
|
|
|
|
|
className,
|
|
|
|
|
|
onSave,
|
|
|
|
|
|
hasUnsavedChanges = false,
|
|
|
|
|
|
isSaving = false,
|
|
|
|
|
|
defaultEditing = false,
|
2026-02-18 03:49:28 +05:30
|
|
|
|
preset = "full",
|
|
|
|
|
|
extraPlugins = [],
|
2026-02-16 00:11:34 +05:30
|
|
|
|
}: PlateEditorProps) {
|
2026-02-17 12:47:39 +05:30
|
|
|
|
const lastMarkdownRef = useRef(markdown);
|
2026-03-22 02:42:51 +05:30
|
|
|
|
const lastHtmlRef = useRef(html);
|
2026-02-16 00:11:34 +05:30
|
|
|
|
|
2026-02-18 00:26:18 +05:30
|
|
|
|
// Keep a stable ref to the latest onSave callback so the plugin shortcut
|
|
|
|
|
|
// always calls the most recent version without re-creating the editor.
|
|
|
|
|
|
const onSaveRef = useRef(onSave);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onSaveRef.current = onSave;
|
|
|
|
|
|
}, [onSave]);
|
|
|
|
|
|
|
|
|
|
|
|
const SaveShortcutPlugin = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
createPlatePlugin({
|
|
|
|
|
|
key: "save-shortcut",
|
|
|
|
|
|
shortcuts: {
|
|
|
|
|
|
save: {
|
2026-03-22 23:06:18 +05:30
|
|
|
|
keys: [[Key.Mod, Key.Shift, "s"]],
|
2026-02-18 00:26:18 +05:30
|
|
|
|
handler: () => {
|
|
|
|
|
|
onSaveRef.current?.();
|
|
|
|
|
|
},
|
|
|
|
|
|
preventDefault: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
[]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-18 03:49:28 +05:30
|
|
|
|
// Resolve the plugin set from the chosen preset
|
|
|
|
|
|
const presetPlugins = presetMap[preset];
|
|
|
|
|
|
|
2026-02-17 12:47:39 +05:30
|
|
|
|
// When readOnly is forced, always start in readOnly.
|
|
|
|
|
|
// Otherwise, respect defaultEditing to decide initial mode.
|
|
|
|
|
|
// The user can still toggle between editing/viewing via ModeToolbarButton.
|
|
|
|
|
|
const editor = usePlateEditor({
|
|
|
|
|
|
readOnly: readOnly || !defaultEditing,
|
|
|
|
|
|
plugins: [
|
2026-02-18 03:49:28 +05:30
|
|
|
|
...presetPlugins,
|
|
|
|
|
|
// Only register save shortcut when a save handler is provided
|
|
|
|
|
|
...(onSave ? [SaveShortcutPlugin] : []),
|
|
|
|
|
|
// Consumer-provided extra plugins
|
|
|
|
|
|
...extraPlugins,
|
2026-02-17 12:47:39 +05:30
|
|
|
|
MarkdownPlugin.configure({
|
|
|
|
|
|
options: {
|
|
|
|
|
|
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
2026-03-22 02:42:51 +05:30
|
|
|
|
value: html
|
|
|
|
|
|
? (editor) => editor.api.html.deserialize({ element: html }) as Value
|
|
|
|
|
|
: markdown
|
|
|
|
|
|
? (editor) =>
|
|
|
|
|
|
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
|
|
|
|
|
|
: undefined,
|
2026-02-17 12:47:39 +05:30
|
|
|
|
});
|
2026-02-16 00:11:34 +05:30
|
|
|
|
|
2026-03-22 02:42:51 +05:30
|
|
|
|
// Update editor content when html prop changes externally
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (html !== undefined && html !== lastHtmlRef.current) {
|
|
|
|
|
|
lastHtmlRef.current = html;
|
|
|
|
|
|
const newValue = editor.api.html.deserialize({ element: html });
|
|
|
|
|
|
editor.tf.reset();
|
|
|
|
|
|
editor.tf.setValue(newValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [html, editor]);
|
|
|
|
|
|
|
2026-02-17 12:47:39 +05:30
|
|
|
|
// Update editor content when markdown prop changes externally
|
|
|
|
|
|
// (e.g., version switching in report panel)
|
|
|
|
|
|
useEffect(() => {
|
2026-03-22 02:42:51 +05:30
|
|
|
|
if (!html && markdown !== undefined && markdown !== lastMarkdownRef.current) {
|
2026-02-17 12:47:39 +05:30
|
|
|
|
lastMarkdownRef.current = markdown;
|
|
|
|
|
|
const newValue = editor
|
|
|
|
|
|
.getApi(MarkdownPlugin)
|
|
|
|
|
|
.markdown.deserialize(escapeMdxExpressions(markdown));
|
|
|
|
|
|
editor.tf.reset();
|
|
|
|
|
|
editor.tf.setValue(newValue);
|
|
|
|
|
|
}
|
2026-03-22 02:42:51 +05:30
|
|
|
|
}, [html, markdown, editor]);
|
2026-02-16 00:11:34 +05:30
|
|
|
|
|
2026-02-17 12:47:39 +05:30
|
|
|
|
// When not forced read-only, the user can toggle between editing/viewing.
|
|
|
|
|
|
const canToggleMode = !readOnly;
|
2026-02-17 01:30:38 +05:30
|
|
|
|
|
2026-02-17 12:47:39 +05:30
|
|
|
|
return (
|
|
|
|
|
|
<EditorSaveContext.Provider
|
|
|
|
|
|
value={{
|
|
|
|
|
|
onSave,
|
|
|
|
|
|
hasUnsavedChanges,
|
|
|
|
|
|
isSaving,
|
|
|
|
|
|
canToggleMode,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plate
|
|
|
|
|
|
editor={editor}
|
|
|
|
|
|
// Only pass readOnly as a controlled prop when forced (permanently read-only).
|
|
|
|
|
|
// For non-forced mode, the Plate store manages readOnly internally
|
|
|
|
|
|
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
|
|
|
|
|
|
{...(readOnly ? { readOnly: true } : {})}
|
|
|
|
|
|
onChange={({ value }) => {
|
2026-03-22 02:42:51 +05:30
|
|
|
|
if (onHtmlChange && html) {
|
|
|
|
|
|
const serialized = slateToHtml(value as Descendant[]);
|
|
|
|
|
|
onHtmlChange(serialized);
|
|
|
|
|
|
} else if (onMarkdownChange) {
|
2026-02-17 12:47:39 +05:30
|
|
|
|
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
|
|
|
|
|
|
lastMarkdownRef.current = md;
|
|
|
|
|
|
onMarkdownChange(md);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<EditorContainer variant={variant} className={className}>
|
|
|
|
|
|
<Editor variant={editorVariant} placeholder={placeholder} />
|
|
|
|
|
|
</EditorContainer>
|
|
|
|
|
|
</Plate>
|
|
|
|
|
|
</EditorSaveContext.Provider>
|
|
|
|
|
|
);
|
2026-02-16 00:11:34 +05:30
|
|
|
|
}
|