11import { EditorState } from "@codemirror/state" ;
22import { placeholder as cmPlaceholder , EditorView } from "@codemirror/view" ;
3- import { forwardRef , useEffect , useImperativeHandle , useLayoutEffect , useMemo , useRef } from "react" ;
3+ import { forwardRef , useCallback , useImperativeHandle , useLayoutEffect , useMemo , useRef } from "react" ;
44import { useTagCounts } from "@/hooks/useUserQueries" ;
55import { cn } from "@/lib/utils" ;
66import type { EditorController } from "../types/editorController" ;
@@ -12,21 +12,37 @@ import { createFormattingController } from "./formatting";
1212interface EditorProps {
1313 className : string ;
1414 initialContent : string ;
15+ contentIsExternal ?: boolean ;
1516 placeholder : string ;
1617 onContentChange : ( content : string ) => void ;
18+ onExternalContentApplied ?: ( content : string ) => void ;
1719 onFiles : ( files : File [ ] , position : number ) => void ;
1820 /** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
1921 onSubmit : ( ) => void ;
2022 isFocusMode ?: boolean ;
2123}
2224
2325const Editor = forwardRef ( function Editor ( props : EditorProps , ref : React . ForwardedRef < EditorController > ) {
24- const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props ;
26+ const {
27+ className,
28+ initialContent,
29+ contentIsExternal = true ,
30+ placeholder,
31+ onContentChange,
32+ onExternalContentApplied,
33+ onFiles,
34+ onSubmit,
35+ isFocusMode,
36+ } = props ;
2537 const hostRef = useRef < HTMLDivElement > ( null ) ;
2638 const viewRef = useRef < EditorView | null > ( null ) ;
2739 const controllerRef = useRef < EditorController | null > ( null ) ;
40+ const applyingExternalContentRef = useRef ( false ) ;
41+ const pendingExternalContentRef = useRef < string | null > ( null ) ;
2842 const onChangeRef = useRef ( onContentChange ) ;
2943 onChangeRef . current = onContentChange ;
44+ const onExternalContentAppliedRef = useRef ( onExternalContentApplied ) ;
45+ onExternalContentAppliedRef . current = onExternalContentApplied ;
3046 const onFilesRef = useRef ( onFiles ) ;
3147 onFilesRef . current = onFiles ;
3248 const onSubmitRef = useRef ( onSubmit ) ;
@@ -40,6 +56,18 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
4056 const tagsRef = useRef ( tags ) ;
4157 tagsRef . current = tags ;
4258
59+ const applyExternalContent = useCallback ( ( content : string ) => {
60+ pendingExternalContentRef . current = null ;
61+ const controller = controllerRef . current ;
62+ if ( ! controller || controller . getMarkdown ( ) === content ) return ;
63+ applyingExternalContentRef . current = true ;
64+ try {
65+ controller . setMarkdown ( content ) ;
66+ } finally {
67+ applyingExternalContentRef . current = false ;
68+ }
69+ } , [ ] ) ;
70+
4371 // useLayoutEffect (not useEffect) so the EditorView — and its placeholder —
4472 // mount before the browser paints. With useEffect the first painted frame
4573 // shows an empty host, then the placeholder pops in (a load flicker).
@@ -50,7 +78,9 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
5078 doc : initialContent ,
5179 extensions : buildEditorExtensions ( {
5280 placeholder,
53- onChange : ( md ) => onChangeRef . current ( md ) ,
81+ onChange : ( md ) => {
82+ if ( ! applyingExternalContentRef . current ) onChangeRef . current ( md ) ;
83+ } ,
5484 onFiles : ( files , position ) => onFilesRef . current ( files , position ) ,
5585 onUpdate : ( ) => listenersRef . current . forEach ( ( l ) => l ( ) ) ,
5686 onSubmit : ( ) => onSubmitRef . current ( ) ,
@@ -61,7 +91,24 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
6191 } ) ;
6292 viewRef . current = view ;
6393 controllerRef . current = createController ( view , createFormattingController ( view , listenersRef . current ) ) ;
94+ const handleCompositionEnd = ( ) => {
95+ // CodeMirror may flush its final Firefox/Android DOM mutations in a
96+ // microtask after compositionend. Queue behind that flush before
97+ // replacing the document with a deferred external value.
98+ queueMicrotask ( ( ) => {
99+ if ( viewRef . current !== view || view . compositionStarted ) return ;
100+ const pendingContent = pendingExternalContentRef . current ;
101+ if ( pendingContent === null ) return ;
102+ applyExternalContent ( pendingContent ) ;
103+ // The composition may have emitted a newer local value after this
104+ // deferred external value entered the store. Reassert the applied
105+ // external value there too.
106+ onExternalContentAppliedRef . current ?.( pendingContent ) ;
107+ } ) ;
108+ } ;
109+ view . contentDOM . addEventListener ( "compositionend" , handleCompositionEnd ) ;
64110 return ( ) => {
111+ view . contentDOM . removeEventListener ( "compositionend" , handleCompositionEnd ) ;
65112 view . destroy ( ) ;
66113 viewRef . current = null ;
67114 controllerRef . current = null ;
@@ -76,12 +123,16 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
76123 viewRef . current ?. dispatch ( { effects : placeholderCompartment . reconfigure ( cmPlaceholder ( placeholder ) ) } ) ;
77124 } , [ placeholder ] ) ;
78125
79- useEffect ( ( ) => {
126+ useLayoutEffect ( ( ) => {
127+ if ( ! contentIsExternal ) return ;
80128 const view = viewRef . current ;
81129 if ( ! view ) return ;
82- if ( view . state . doc . toString ( ) === initialContent ) return ;
83- view . dispatch ( { changes : { from : 0 , to : view . state . doc . length , insert : initialContent } } ) ;
84- } , [ initialContent ] ) ;
130+ if ( view . compositionStarted ) {
131+ pendingExternalContentRef . current = initialContent ;
132+ return ;
133+ }
134+ applyExternalContent ( initialContent ) ;
135+ } , [ applyExternalContent , contentIsExternal , initialContent ] ) ;
85136
86137 // The controller is created in the mount layout effect above, which runs
87138 // before this (also layout-phase) handle, so controllerRef.current is set.
0 commit comments