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-04-10 00:29:53 +05:30
import { createPlatePlugin , Key , Plate , useEditorReadOnly , 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-04-30 18:40:55 -07:00
import { CitationKit , injectCitationNodes } from "@/components/editor/plugins/citation-kit" ;
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-05-05 19:10:35 -07:00
import { safeDeserializeMarkdown } from "@/components/editor/utils/safe-deserialize" ;
2026-02-20 22:44:56 -08:00
import { Editor , EditorContainer } from "@/components/ui/editor" ;
2026-04-30 18:40:55 -07:00
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser" ;
2026-02-16 00:11:34 +05:30
2026-04-28 23:25:26 -07:00
/** Live editor instance returned by `usePlateEditor`. */
2026-04-28 21:30:53 -07:00
export type PlateEditorInstance = ReturnType < typeof usePlateEditor > ;
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-05-15 09:06:42 -07:00
/** Save callback. When provided, ⌘+Shift+S / Ctrl+Shift+S shortcut is registered (avoiding the browser's ⌘+S / Ctrl+S "Save Page As" conflict) and a save button appears in the toolbar. */
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 ;
2026-04-23 19:52:55 +05:30
/** Whether edit/view mode toggle UI should be available in toolbars. */
allowModeToggle? : boolean ;
2026-04-23 20:13:29 +05:30
/** Reserve fixed-toolbar vertical space even when controls are hidden. */
reserveToolbarSpace? : boolean ;
2026-02-17 12:47:39 +05:30
/** 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-04-30 18:40:55 -07:00
/ * *
* Render ` [citation:N] ` and ` [citation:URL] ` tokens in the deserialized
* markdown as interactive citation badges / popovers ( mirrors chat ) . Only
* meant for read - only views — when true , ` onMarkdownChange ` is suppressed
* because the in - memory tree contains custom inline - void elements that
* have no markdown serialize rule .
* /
enableCitations? : boolean ;
2026-02-16 00:11:34 +05:30
}
2026-04-10 00:29:53 +05:30
function PlateEditorContent ( {
editorVariant ,
placeholder ,
} : {
editorVariant : PlateEditorProps [ "editorVariant" ] ;
placeholder? : string ;
} ) {
const isReadOnly = useEditorReadOnly ( ) ;
return (
< Editor
variant = { editorVariant }
placeholder = { isReadOnly ? undefined : placeholder }
className = "min-h-full"
/ >
) ;
}
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 ,
2026-04-23 19:52:55 +05:30
allowModeToggle = true ,
2026-04-23 20:13:29 +05:30
reserveToolbarSpace = false ,
2026-02-17 12:47:39 +05:30
defaultEditing = false ,
2026-02-18 03:49:28 +05:30
preset = "full" ,
extraPlugins = [ ] ,
2026-04-30 18:40:55 -07:00
enableCitations = false ,
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-04-30 18:40:55 -07:00
// Citation void inline element (read-only document viewer).
. . . ( enableCitations ? CitationKit : [ ] ) ,
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
2026-04-30 18:40:55 -07:00
? ( editor ) = > {
if ( ! enableCitations ) {
2026-05-12 04:00:04 +05:30
return safeDeserializeMarkdown ( editor , escapeMdxExpressions ( markdown ) ) as Value ;
2026-04-30 18:40:55 -07:00
}
const { content : rewritten , urlMap } = preprocessCitationMarkdown ( markdown ) ;
2026-05-12 04:00:04 +05:30
const value = safeDeserializeMarkdown ( editor , escapeMdxExpressions ( rewritten ) ) ;
2026-05-05 19:10:35 -07:00
return injectCitationNodes ( value , urlMap ) as Value ;
2026-04-30 18:40:55 -07:00
}
2026-03-22 02:42:51 +05:30
: 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 ;
2026-04-30 18:40:55 -07:00
let newValue : Descendant [ ] ;
if ( enableCitations ) {
const { content : rewritten , urlMap } = preprocessCitationMarkdown ( markdown ) ;
2026-05-12 04:00:04 +05:30
const deserialized = safeDeserializeMarkdown ( editor , escapeMdxExpressions ( rewritten ) ) ;
2026-04-30 18:40:55 -07:00
newValue = injectCitationNodes ( deserialized , urlMap ) ;
} else {
2026-05-05 19:10:35 -07:00
newValue = safeDeserializeMarkdown ( editor , escapeMdxExpressions ( markdown ) ) ;
2026-04-30 18:40:55 -07:00
}
2026-02-17 12:47:39 +05:30
editor . tf . reset ( ) ;
2026-04-30 18:40:55 -07:00
editor . tf . setValue ( newValue as Value ) ;
2026-02-17 12:47:39 +05:30
}
2026-04-30 18:40:55 -07:00
} , [ html , markdown , editor , enableCitations ] ) ;
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.
2026-04-23 19:52:55 +05:30
const canToggleMode = ! readOnly && allowModeToggle ;
2026-02-17 01:30:38 +05:30
2026-04-07 05:55:39 +05:30
const contextProviderValue = useMemo (
( ) = > ( {
onSave ,
hasUnsavedChanges ,
isSaving ,
canToggleMode ,
2026-04-23 20:13:29 +05:30
reserveToolbarSpace ,
2026-04-07 05:55:39 +05:30
} ) ,
2026-04-23 20:13:29 +05:30
[ onSave , hasUnsavedChanges , isSaving , canToggleMode , reserveToolbarSpace ]
2026-04-07 05:55:39 +05:30
) ;
2026-04-03 23:23:54 +05:30
2026-02-17 12:47:39 +05:30
return (
2026-04-07 05:55:39 +05:30
< EditorSaveContext.Provider value = { contextProviderValue } >
2026-02-17 12:47:39 +05:30
< 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-04-30 18:40:55 -07:00
// View-only citation mode: skip serialization. The custom
// `citation` inline-void element has no markdown serialize
// rule, so emitting changes here would overwrite
// `lastMarkdownRef.current` (and downstream copy-to-clipboard
// state in EditorPanelContent) with a tree that loses every
// citation token. `enableCitations` is only ever set in
// read-only paths, so user input cannot reach this branch
// in practice — the guard exists for the initial Plate
// normalize emit.
if ( enableCitations ) return ;
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 } >
2026-04-10 00:29:53 +05:30
< PlateEditorContent editorVariant = { editorVariant } placeholder = { placeholder } / >
2026-02-17 12:47:39 +05:30
< / EditorContainer >
< / Plate >
< / EditorSaveContext.Provider >
) ;
2026-02-16 00:11:34 +05:30
}