import { forwardRef, useEffect, useRef, useImperativeHandle, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react' import { Mark, mergeAttributes, getMarkRange } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import Underline from '@tiptap/extension-underline' import CharacterCount from '@tiptap/extension-character-count' import TextAlign from '@tiptap/extension-text-align' import Highlight from '@tiptap/extension-highlight' import Toolbar from './Toolbar' import ImageView from './ImageView' import { api } from '../lib/api' // ── Custom image extension ───────────────────────────────────────────────── const CustomImage = Image.extend({ addAttributes() { return { ...this.parent?.(), width: { default: '100%' }, align: { default: 'center' }, } }, addNodeView() { return ReactNodeViewRenderer(ImageView) }, }).configure({ allowBase64: false, inline: false }) // ── ReadingMark — transient mark that follows the TTS playhead ──────────── const ReadingMark = Mark.create({ name: 'readingWord', inclusive: false, parseHTML() { return [] }, // never restore from HTML — transient UI only renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes({ class: 'reading-word' }, HTMLAttributes), 0] }, }) // ── LintMark — transient mark for spelling / grammar underlines ──────────── const LintMark = Mark.create({ name: 'lintError', inclusive: false, // don't extend when typing at a boundary addAttributes() { return { kind: { default: 'grammar' }, // 'spelling' | 'grammar' message: { default: '' }, replacements: { default: [], parseHTML: el => JSON.parse(el.getAttribute('data-r') || '[]'), renderHTML: att => ({ 'data-r': JSON.stringify(att.replacements ?? []) }), }, } }, parseHTML() { return [] }, // never parse from saved HTML — marks are transient renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes( { 'data-lint': '', class: `lint-mark lint-mark--${HTMLAttributes.kind}` }, HTMLAttributes, ), 0] }, }) // ── Lint helpers ─────────────────────────────────────────────────────────── // Build plain text + ProseMirror position map from the doc function buildTextMap(doc) { const text = [], posMap = [] doc.descendants((node, pos) => { if (node.isText) { for (let i = 0; i < node.text.length; i++) { text.push(node.text[i]) posMap.push(pos + i) } return false } // Paragraph / heading separators → '\n' (no PM position — null in map) if (node.isBlock && text.length > 0 && text[text.length - 1] !== '\n') { text.push('\n') posMap.push(null) } }) return { text: text.join(''), posMap } } // Remove all lint marks in one transaction function clearLintMarks(editor) { const { tr, doc, schema } = editor.state const mt = schema.marks.lintError if (!mt) return doc.descendants((node, pos) => { if (!node.isText) return node.marks.filter(m => m.type === mt) .forEach(() => tr.removeMark(pos, pos + node.nodeSize, mt)) }) editor.view.dispatch(tr) } // Strip transient UI marks (lint, reading playhead) from JSON before saving function stripLintMarks(node) { if (!node) return node const n = { ...node } if (n.marks) { n.marks = n.marks.filter(m => m.type !== 'lintError' && m.type !== 'readingWord') if (!n.marks.length) delete n.marks } if (n.content) n.content = n.content.map(stripLintMarks) return n } // ── Editor component ─────────────────────────────────────────────────────── const Editor = forwardRef(function Editor( { content, onChange, onImageUpload, fontSize, onFontSizeChange }, ref, ) { const synced = useRef(false) // ── Lint state ─────────────────────────────────────────────────────────── const [lintStatus, setLintStatus] = useState('idle') // idle | checking | done | stale const [lintCount, setLintCount] = useState(0) const [lintPopover, setLintPopover] = useState(null) // { top, left, message, replacements, from, to } const lintStatusRef = useRef('idle') const lintDebounce = useRef(null) const runLintRef = useRef(null) // always holds latest runLint, safe to call from onUpdate closure const applyingLints = useRef(false) // true while dispatching mark transactions — suppresses onUpdate const editor = useEditor({ extensions: [ StarterKit, Underline, CustomImage, LintMark, ReadingMark, Placeholder.configure({ placeholder: 'Begin your story here…' }), CharacterCount, TextAlign.configure({ types: ['heading', 'paragraph'] }), Highlight.configure({ multicolor: true }), ], content: '', onUpdate({ editor }) { // Skip when the update was caused by us applying / clearing lint marks. // Without this guard, dispatching mark transactions fires onUpdate which // reschedules the lint check, which applies marks, which fires onUpdate… ∞ if (applyingLints.current) return onChange(stripLintMarks(editor.getJSON())) // Debounced auto-check: fire 1.5 s after the last keystroke clearTimeout(lintDebounce.current) lintDebounce.current = setTimeout(() => runLintRef.current?.(), 3000) }, }) useImperativeHandle(ref, () => ({ insertPrompt: (text) => { if (!editor) return editor.chain().focus().insertContent({ type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], }).run() }, })) useEffect(() => { if (editor && !synced.current) { const hasContent = content && Object.keys(content).length > 0 if (hasContent) editor.commands.setContent(content, false) synced.current = true } }, [editor, content]) // ── Lint check ─────────────────────────────────────────────────────────── const runLint = useCallback(async () => { if (!editor || lintStatusRef.current === 'checking') return lintStatusRef.current = 'checking' setLintStatus('checking') // Don't close an open popover here — it gets wiped on every auto-check // and the user can't interact with it. It closes naturally on click-outside. try { const { text, posMap } = buildTextMap(editor.state.doc) if (!text.trim()) { applyingLints.current = true clearLintMarks(editor) applyingLints.current = false setLintCount(0) setLintStatus('done') lintStatusRef.current = 'done' return } const data = await api.lintCheck(text) // Clear old marks then apply new ones — both wrapped in the flag so // the resulting onUpdate calls are silently ignored. applyingLints.current = true clearLintMarks(editor) const { tr, schema } = editor.state const lintType = schema.marks.lintError for (const match of data.matches) { let pmFrom = null, pmTo = null for (let i = match.offset; i < match.offset + match.length && i < posMap.length; i++) { if (posMap[i] !== null) { if (pmFrom === null) pmFrom = posMap[i] pmTo = posMap[i] + 1 } } if (pmFrom === null) continue tr.addMark(pmFrom, pmTo, lintType.create({ kind: match.kind, message: match.message, replacements: match.replacements, })) } editor.view.dispatch(tr) applyingLints.current = false setLintCount(data.matches.length) setLintStatus('done') lintStatusRef.current = 'done' } catch (err) { applyingLints.current = false console.error('[lint] Check failed:', err) setLintStatus('idle') lintStatusRef.current = 'idle' } }, [editor]) // Keep ref in sync so the stale onUpdate closure always calls the latest runLint useEffect(() => { runLintRef.current = runLint }, [runLint]) // Clean up the debounce timer on unmount useEffect(() => () => clearTimeout(lintDebounce.current), []) // ── Reading-word mark (TTS playhead highlight) ──────────────────────────── // Called by Toolbar on each word-boundary event. Both operations are wrapped // in applyingLints so the resulting onUpdate dispatch is silently ignored — // the same guard that protects lint mark dispatches also covers these. const applyReadingMark = useCallback((from, to) => { if (!editor) return const mt = editor.state.schema.marks.readingWord if (!mt) return const { tr } = editor.state // Clear any existing reading mark in one shot then set the new one editor.state.doc.descendants((node, pos) => { if (!node.isText) return node.marks.filter(m => m.type === mt) .forEach(() => tr.removeMark(pos, pos + node.nodeSize, mt)) }) tr.addMark(from, to, mt.create()) applyingLints.current = true editor.view.dispatch(tr) applyingLints.current = false }, [editor]) const clearReadingMark = useCallback(() => { if (!editor) return const mt = editor.state.schema.marks.readingWord if (!mt) return const { tr } = editor.state editor.state.doc.descendants((node, pos) => { if (!node.isText) return node.marks.filter(m => m.type === mt) .forEach(() => tr.removeMark(pos, pos + node.nodeSize, mt)) }) applyingLints.current = true editor.view.dispatch(tr) applyingLints.current = false }, [editor]) // ── Popover on clicking a lint mark ────────────────────────────────────── function handleEditorClick(e) { if (!editor) return const target = e.target.closest('[data-lint]') if (!target) { setLintPopover(null); return } const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }) if (!coords) return const $pos = editor.state.doc.resolve(coords.pos) const lintMT = editor.state.schema.marks.lintError const mark = $pos.marks().find(m => m.type === lintMT) if (!mark) return const range = getMarkRange($pos, lintMT) if (!range) return const rect = target.getBoundingClientRect() setLintPopover({ // Fixed position so we don't have to fight scroll offsets top: rect.bottom + 8, left: Math.max(8, Math.min(rect.left, window.innerWidth - 270)), message: mark.attrs.message, replacements: mark.attrs.replacements || [], from: range.from, to: range.to, }) } function applyReplacement(replacement) { if (!lintPopover || !editor) return const { from, to } = lintPopover editor.chain().setTextSelection({ from, to }).insertContent(replacement).focus().run() setLintPopover(null) // Re-run lint after a short pause so the new text gets checked setTimeout(runLint, 600) } function dismissLintMark() { if (!lintPopover || !editor) return const { from, to } = lintPopover const { tr, schema } = editor.state tr.removeMark(from, to, schema.marks.lintError) editor.view.dispatch(tr) setLintPopover(null) setLintCount(c => Math.max(0, c - 1)) } const wordCount = editor?.storage.characterCount.words() ?? 0 return (
{lintPopover.message}
{lintPopover.replacements.length > 0 && (