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:
chris 2026-05-24 21:35:38 -04:00
parent a8f93582bf
commit 55375d2ff0
6 changed files with 376 additions and 5 deletions

View File

@ -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>
)
})

View File

@ -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' : ''}`}

View File

@ -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`),

View File

@ -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 */

View File

@ -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
View 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