- Add ReadingMark TipTap extension (transient, never saved to DB) that renders the active TTS passage as <span class='reading-word'> - Build a char→ProseMirror position map on read-start so boundary events can pinpoint exact document positions - Use onstart (fires on every utterance/voice) for reliable sentence-level highlight; onboundary overrides with word-level when the voice supports it - Auto-scroll the highlighted span into view (smooth, centred) on each update - Strip readingWord marks from JSON alongside lintError before saving - Guard all mark dispatches with applyingLints flag to suppress spurious saves and lint re-checks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
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 (
|
|
<div className="editor-outer" style={{ '--editor-font-size': (fontSize || 17) + 'px' }}>
|
|
<Toolbar
|
|
editor={editor}
|
|
onImageUpload={onImageUpload}
|
|
fontSize={fontSize}
|
|
onFontSizeChange={onFontSizeChange}
|
|
onLint={runLint}
|
|
lintStatus={lintStatus}
|
|
lintCount={lintCount}
|
|
applyReadingMark={applyReadingMark}
|
|
clearReadingMark={clearReadingMark}
|
|
/>
|
|
<div className="editor-wrap" onClick={handleEditorClick}>
|
|
<EditorContent editor={editor} className="editor-body" spellCheck={false} />
|
|
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
|
</div>
|
|
|
|
{lintPopover && createPortal(
|
|
<div
|
|
className="lint-popover"
|
|
style={{ top: lintPopover.top, left: lintPopover.left }}
|
|
// Prevent mousedown from stealing focus away from the editor —
|
|
// without this the editor blurs and the popover may vanish before click fires
|
|
onMouseDown={e => e.preventDefault()}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<p className="lint-popover-msg">{lintPopover.message}</p>
|
|
{lintPopover.replacements.length > 0 && (
|
|
<div className="lint-suggestions">
|
|
{lintPopover.replacements.map((r, i) => (
|
|
<button key={i} className="lint-suggestion" onClick={() => applyReplacement(r)}>{r}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="lint-popover-actions">
|
|
<button className="btn btn-ghost lint-ignore" onClick={dismissLintMark}>Ignore</button>
|
|
<button className="btn btn-ghost lint-close" onClick={() => setLintPopover(null)}>✕</button>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default Editor
|