chris 4db65151c8 feat: read-aloud highlights and scrolls to current sentence/word
- 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>
2026-05-24 22:48:27 -04:00

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