// Editor.jsx — the Discuss-mode read-only renderer (and main/no-contribute // fallback). Tiptap on ProseMirror per §18; the Contribute-mode editing // surface moved to MarkdownSourceEditor (CodeMirror 6) in the Phase 1 // rewrite of Contribute, leaving this surface for read-only rendering // plus the §8.12 selection tooltip's coords. // // One ProseMirror plugin still lives alongside StarterKit: // // • selectionHighlight — keeps a selected passage highlighted while // focus moves to the §8.12 selection tooltip. Driven by meta // transactions dispatched from the parent. // // The paragraph-margin gutter accent that used to live here is dropped; // Phase 4 of the Contribute rewrite adds a proper change-anchored gutter // against the CodeMirror raw pane. import { useEditor, EditorContent, Extension } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { useEffect, useRef, useCallback } from 'react' import { renderMarkdown } from '../lib/sanitizeHtml' import { Plugin, PluginKey } from 'prosemirror-state' import { Decoration, DecorationSet } from 'prosemirror-view' // ── Selection highlight plugin ──────────────────────────────────────────── export const selectionHighlightKey = new PluginKey('selectionHighlight') function makeSelectionHighlightPlugin() { return new Plugin({ key: selectionHighlightKey, state: { init: () => null, apply(tr, prev) { const meta = tr.getMeta(selectionHighlightKey) return meta !== undefined ? meta : prev }, }, props: { decorations(state) { const range = selectionHighlightKey.getState(state) if (!range || range.from >= range.to) return DecorationSet.empty try { return DecorationSet.create(state.doc, [ Decoration.inline(range.from, range.to, { class: 'selection-highlight' }), ]) } catch { return DecorationSet.empty } }, }, }) } function SelectionHighlightExtension() { return Extension.create({ name: 'selectionHighlight', addProseMirrorPlugins() { return [makeSelectionHighlightPlugin()] }, }) } // ── Editor component ────────────────────────────────────────────────────── export default function Editor({ content, editorRef, onSelectionChange, onUpdate, editable = true, }) { const isMouseDownRef = useRef(false) const reportSelection = useCallback((editor) => { if (isMouseDownRef.current) return const { from, to } = editor.state.selection if (from !== to) { const text = editor.state.doc.textBetween(from, to, ' ') const coords = editor.view.coordsAtPos(from) onSelectionChange?.({ text, coords, from, to }) } else { onSelectionChange?.(null) } }, [onSelectionChange]) const editor = useEditor({ extensions: [ StarterKit, SelectionHighlightExtension(), ], content: '
', editable, onUpdate: ({ editor }) => { onUpdate?.(editor.getText(), editor.getHTML()) }, onSelectionUpdate: ({ editor }) => { reportSelection(editor) }, }) // Expose editor instance to the parent. useEffect(() => { if (editorRef) editorRef.current = editor }, [editor, editorRef]) useEffect(() => { if (editor) editor.setEditable(editable) }, [editor, editable]) useEffect(() => { if (!editor) return const el = editor.view.dom const onMouseDown = () => { isMouseDownRef.current = true; onSelectionChange?.(null) } const onMouseUp = () => { isMouseDownRef.current = false; reportSelection(editor) } el.addEventListener('mousedown', onMouseDown) document.addEventListener('mouseup', onMouseUp) return () => { el.removeEventListener('mousedown', onMouseDown) document.removeEventListener('mouseup', onMouseUp) } }, [editor, onSelectionChange, reportSelection]) useEffect(() => { if (!editor || content == null) return const html = renderMarkdown(content) editor.commands.setContent(html, false) }, [content, editor]) return (