({
value,
onChange,
placeholder,
disabled = false,
rows,
autoFocus = false,
onEditorReady,
useMarkdown = true
}: RichTextEditorProps)
| 161 | * business logic. Variable/mention support lives in the features layer. |
| 162 | */ |
| 163 | export function RichTextEditor({ |
| 164 | value, |
| 165 | onChange, |
| 166 | placeholder, |
| 167 | disabled = false, |
| 168 | rows, |
| 169 | autoFocus = false, |
| 170 | onEditorReady, |
| 171 | useMarkdown = true |
| 172 | }: RichTextEditorProps) { |
| 173 | // Keep a ref to the latest onChange so the TipTap onUpdate callback never goes stale |
| 174 | const onChangeRef = useRef(onChange) |
| 175 | useEffect(() => { |
| 176 | onChangeRef.current = onChange |
| 177 | }, [onChange]) |
| 178 | |
| 179 | // Track the last value we emitted (or loaded) to avoid re-setting content |
| 180 | // when the parent echoes our own onChange value back as the new prop. |
| 181 | const lastEmittedRef = useRef<string>(value || '') |
| 182 | |
| 183 | // Capture initial value and useMarkdown for the mount effect below so we can |
| 184 | // depend only on `editor` without suppressing the exhaustive-deps rule. |
| 185 | const initialValueRef = useRef(value) |
| 186 | const useMarkdownRef = useRef(useMarkdown) |
| 187 | |
| 188 | const extensions = useMemo(() => buildExtensions(placeholder, useMarkdown), [placeholder, useMarkdown]) |
| 189 | |
| 190 | const editor = useEditor({ |
| 191 | extensions, |
| 192 | content: '', |
| 193 | editable: !disabled, |
| 194 | autofocus: autoFocus ? 'end' : false, |
| 195 | onUpdate: ({ editor: ed }) => { |
| 196 | const raw = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML() |
| 197 | const emitted = useMarkdown ? unescapeXmlTags(raw) : raw |
| 198 | lastEmittedRef.current = emitted |
| 199 | onChangeRef.current(emitted) |
| 200 | } |
| 201 | }) |
| 202 | |
| 203 | // Notify parent when the editor instance is ready (used by ExpandTextDialog to flush |
| 204 | // the current editor state to markdown when switching to Source mode). |
| 205 | useEffect(() => { |
| 206 | onEditorReady?.(editor) |
| 207 | return () => onEditorReady?.(null) |
| 208 | }, [editor, onEditorReady]) |
| 209 | |
| 210 | // Load initial content once the editor is ready, detecting legacy HTML vs markdown. |
| 211 | // Reads from refs so only `editor` needs to be in the dep array. |
| 212 | useEffect(() => { |
| 213 | if (!editor || !initialValueRef.current) return |
| 214 | loadContent(editor, initialValueRef.current, useMarkdownRef.current) |
| 215 | lastEmittedRef.current = initialValueRef.current |
| 216 | }, [editor]) |
| 217 | |
| 218 | // Sync genuine external value changes (e.g. parent resets the field programmatically). |
| 219 | useEffect(() => { |
| 220 | if (editor && value !== lastEmittedRef.current) { |
nothing calls this directly
no test coverage detected