Built-in spell & grammar checker (LanguageTool)
Server: - New POST /api/lint/check proxies to LanguageTool API (public free endpoint by default; override with LANGUAGETOOL_URL env var for self-hosted instance) - Returns trimmed match list: message, offset, length, replacements, kind (spelling|grammar), ruleId - Disables cosmetic rules that don't suit creative writing - 20-second timeout; falls back with 502 on error Frontend: - LintMark: transient TipTap Mark extension (never saved to DB — stripLintMarks() removes them from getJSON() before onChange fires) - buildTextMap(): walks ProseMirror doc tree, builds parallel plain- text string + position array so LanguageTool char offsets map back to exact PM positions even across paragraphs / headings - clearLintMarks() + runLint(): check → clear old marks → apply new marks in one transaction, no flicker - Click on underlined text → fixed-position popover showing the error message + up to 6 one-click replacement buttons + Ignore / ✕ - Applying a replacement triggers an automatic re-check after 600 ms - lintStatus: idle → checking → done → stale (when user edits) - ABC toolbar button shows count badge when errors found, ✓ when clean - Wavy red underline = spelling, wavy blue = grammar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a8f93582bf
commit
55375d2ff0
@ -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}
|
||||
/>
|
||||
<div className="editor-wrap">
|
||||
<div className="editor-wrap" onClick={handleEditorClick}>
|
||||
<EditorContent editor={editor} className="editor-body" />
|
||||
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
||||
</div>
|
||||
|
||||
{lintPopover && (
|
||||
<div
|
||||
className="lint-popover"
|
||||
style={{ top: lintPopover.top, left: lintPopover.left }}
|
||||
// Stop clicks inside the popover from bubbling to the editor click handler
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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
|
||||
⌨
|
||||
</button>
|
||||
|
||||
{onLint && (
|
||||
<button
|
||||
className={`toolbar-btn lint-btn${
|
||||
lintStatus === 'done' && lintCount === 0 ? ' lint-clean' :
|
||||
lintStatus === 'done' && lintCount > 0 ? ' lint-errors' :
|
||||
lintStatus === 'stale' ? ' lint-stale' : ''
|
||||
}`}
|
||||
onMouseDown={e => { e.preventDefault(); onLint() }}
|
||||
disabled={lintStatus === 'checking'}
|
||||
title={
|
||||
lintStatus === 'checking' ? 'Checking…' :
|
||||
lintStatus === 'done' && lintCount === 0 ? 'No issues found — click to re-check' :
|
||||
lintStatus === 'done' ? `${lintCount} issue${lintCount !== 1 ? 's' : ''} — click to re-check` :
|
||||
lintStatus === 'stale' ? 'Text changed — click to re-check' :
|
||||
'Check spelling & grammar'
|
||||
}
|
||||
>
|
||||
{lintStatus === 'checking' ? '…' :
|
||||
lintStatus === 'done' && lintCount === 0 ? '✓' :
|
||||
lintStatus === 'done' ? `${lintCount}` :
|
||||
lintStatus === 'stale' ? 'ABC*' :
|
||||
'ABC'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="tts-settings-wrap">
|
||||
<button
|
||||
className={`toolbar-btn${showTtsMenu ? ' active' : ''}`}
|
||||
|
||||
@ -78,6 +78,8 @@ export const api = {
|
||||
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
|
||||
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
|
||||
|
||||
lintCheck: (text, language = 'en-US') => req('POST', '/api/lint/check', { text, language }),
|
||||
|
||||
getPrompt: () => req('GET', '/api/prompts'),
|
||||
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
||||
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
||||
|
||||
@ -1479,6 +1479,77 @@ button { cursor: pointer; font-family: inherit; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Spell / Grammar Check ────────────────────────────── */
|
||||
|
||||
/* Wavy underlines on lint-marked text */
|
||||
.lint-mark {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-thickness: 1.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lint-mark--spelling { text-decoration-color: #f87171; } /* red */
|
||||
.lint-mark--grammar { text-decoration-color: #60a5fa; } /* blue */
|
||||
|
||||
/* Toolbar lint button states */
|
||||
.lint-btn { font-family: var(--font-head); font-size: 0.75rem; min-width: 36px; }
|
||||
.lint-btn.lint-clean { color: #4ade80; border-color: #4ade8040; }
|
||||
.lint-btn.lint-errors { color: #f87171; border-color: #f8717140; }
|
||||
.lint-btn.lint-stale { color: var(--text-muted); opacity: 0.7; }
|
||||
.lint-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||
|
||||
/* Popover */
|
||||
.lint-popover {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
width: 260px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) * 2);
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.lint-popover-msg {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.lint-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lint-suggestion {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 1px);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.lint-suggestion:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
|
||||
.lint-popover-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.lint-ignore { font-size: 0.75rem; padding: 0.2rem 0.5rem; }
|
||||
.lint-close { font-size: 0.85rem; padding: 0.2rem 0.45rem; }
|
||||
|
||||
/* ── Typewriter Mode ──────────────────────────────────── */
|
||||
|
||||
/* Extra bottom padding so the last line can scroll to screen centre */
|
||||
|
||||
@ -7,6 +7,7 @@ import imagesRoutes from './routes/images.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import promptsRoutes from './routes/prompts.js'
|
||||
import notesRoutes from './routes/notes.js'
|
||||
import lintRoutes from './routes/lint.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const app = express()
|
||||
@ -20,5 +21,6 @@ app.use('/api/images', imagesRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/api/prompts', promptsRoutes)
|
||||
app.use('/api/stories/:storyId/notes', notesRoutes)
|
||||
app.use('/api/lint', lintRoutes)
|
||||
|
||||
app.listen(3000, () => console.log('Server ready on :3000'))
|
||||
|
||||
55
server/routes/lint.js
Normal file
55
server/routes/lint.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { Router } from 'express'
|
||||
import { auth } from '../middleware/auth.js'
|
||||
|
||||
// Point at a self-hosted LanguageTool instance by setting LANGUAGETOOL_URL.
|
||||
// Falls back to the free public API (rate-limited but good for personal use).
|
||||
const LT_URL = (process.env.LANGUAGETOOL_URL || 'https://api.languagetoolplus.com/v2').replace(/\/$/, '')
|
||||
|
||||
const router = Router()
|
||||
router.use(auth)
|
||||
|
||||
router.post('/check', async (req, res) => {
|
||||
const { text, language = 'en-US' } = req.body || {}
|
||||
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' })
|
||||
if (text.length > 40000) return res.status(400).json({ error: 'text too long (max 40 000 chars)' })
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
text,
|
||||
language,
|
||||
// Suppress cosmetic rules that aren't useful in a creative-writing context
|
||||
disabledRules: 'WHITESPACE_RULE,WORD_CONTAINS_UPPERCASE,EN_UNPAIRED_BRACKETS,DASH_RULE',
|
||||
})
|
||||
|
||||
const r = await fetch(`${LT_URL}/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const msg = await r.text().catch(() => '')
|
||||
throw new Error(`LanguageTool ${r.status}: ${msg.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const data = await r.json()
|
||||
|
||||
// Send only what the client needs — keep the payload small
|
||||
res.json({
|
||||
matches: (data.matches || []).map(m => ({
|
||||
message: m.message,
|
||||
offset: m.offset,
|
||||
length: m.length,
|
||||
replacements: (m.replacements || []).slice(0, 6).map(r => r.value),
|
||||
kind: m.rule?.category?.id === 'TYPOS' ? 'spelling' : 'grammar',
|
||||
ruleId: m.rule?.id ?? '',
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[lint] LanguageTool error:', err.message)
|
||||
res.status(502).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Loading…
x
Reference in New Issue
Block a user