diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index 82fee85..ac25046 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -1,4 +1,5 @@ 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' @@ -113,6 +114,7 @@ const Editor = forwardRef(function Editor( 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 we're dispatching mark transactions — skip onUpdate const editor = useEditor({ extensions: [ @@ -127,10 +129,14 @@ const Editor = forwardRef(function Editor( ], 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?.(), 1500) + lintDebounce.current = setTimeout(() => runLintRef.current?.(), 3000) }, }) @@ -157,12 +163,15 @@ const Editor = forwardRef(function Editor( if (!editor || lintStatusRef.current === 'checking') return lintStatusRef.current = 'checking' setLintStatus('checking') - setLintPopover(null) + // 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' @@ -171,7 +180,9 @@ const Editor = forwardRef(function Editor( const data = await api.lintCheck(text) - // Clear old marks, then apply all new ones in one transaction + // 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 @@ -192,11 +203,13 @@ const Editor = forwardRef(function Editor( })) } 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' @@ -274,11 +287,13 @@ const Editor = forwardRef(function Editor(
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
- {lintPopover && ( + {lintPopover && createPortal(
e.preventDefault()} onClick={e => e.stopPropagation()} >

{lintPopover.message}

@@ -293,7 +308,8 @@ const Editor = forwardRef(function Editor(
- + , + document.body, )} )