diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index f6ae695..27b8042 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -1,5 +1,6 @@ -import { forwardRef, useEffect, useRef, useImperativeHandle } from 'react' +import { forwardRef, useEffect, useRef, useImperativeHandle, useState, useCallback } from 'react' 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' @@ -9,7 +10,9 @@ 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 { @@ -23,14 +26,98 @@ const CustomImage = Image.extend({ }, }).configure({ allowBase64: false, inline: false }) -const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fontSize, onFontSizeChange }, ref) { +// ── 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 lint marks from the JSON before saving (they're UI-only) +function stripLintMarks(node) { + if (!node) return node + const n = { ...node } + if (n.marks) { + n.marks = n.marks.filter(m => m.type !== 'lintError') + 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 editor = useEditor({ extensions: [ StarterKit, Underline, CustomImage, + LintMark, Placeholder.configure({ placeholder: 'Begin your story here…' }), CharacterCount, TextAlign.configure({ types: ['heading', 'paragraph'] }), @@ -38,7 +125,12 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo ], content: '', onUpdate({ editor }) { - onChange(editor.getJSON()) + onChange(stripLintMarks(editor.getJSON())) + // Mark results as stale when the user edits so they know to re-check + if (lintStatusRef.current === 'done') { + lintStatusRef.current = 'stale' + setLintStatus('stale') + } }, }) @@ -60,6 +152,105 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo } }, [editor, content]) + // ── Lint check ─────────────────────────────────────────────────────────── + const runLint = useCallback(async () => { + if (!editor || lintStatusRef.current === 'checking') return + lintStatusRef.current = 'checking' + setLintStatus('checking') + setLintPopover(null) + + try { + const { text, posMap } = buildTextMap(editor.state.doc) + if (!text.trim()) { + clearLintMarks(editor) + setLintCount(0) + setLintStatus('done') + lintStatusRef.current = 'done' + return + } + + const data = await api.lintCheck(text) + + // Clear old marks, then apply all new ones in one transaction + 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) + + setLintCount(data.matches.length) + setLintStatus('done') + lintStatusRef.current = 'done' + } catch (err) { + console.error('[lint] Check failed:', err) + setLintStatus('idle') + lintStatusRef.current = 'idle' + } + }, [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 ( @@ -69,11 +260,36 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo onImageUpload={onImageUpload} fontSize={fontSize} onFontSizeChange={onFontSizeChange} + onLint={runLint} + lintStatus={lintStatus} + lintCount={lintCount} /> -
+
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
+ + {lintPopover && ( +
e.stopPropagation()} + > +

{lintPopover.message}

+ {lintPopover.replacements.length > 0 && ( +
+ {lintPopover.replacements.map((r, i) => ( + + ))} +
+ )} +
+ + +
+
+ )}
) }) diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx index 952e50e..59a53e2 100644 --- a/frontend/src/components/Toolbar.jsx +++ b/frontend/src/components/Toolbar.jsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect, useCallback } from 'react' -export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) { +export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange, onLint, lintStatus, lintCount }) { const fileRef = useRef() const [isReading, setIsReading] = useState(false) const isReadingRef = useRef(false) // ref so closure in next() always sees current value @@ -247,6 +247,31 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha ⌨ + {onLint && ( + + )} +