From 15ef235aa71c3a93dbd49b8c6e58cc2403ba86de Mon Sep 17 00:00:00 2001 From: youdie006 Date: Sat, 20 Jun 2026 09:42:14 +0900 Subject: [PATCH] feat: add spellCheck support to the editor The editor did not enable the browser-native spellchecker - no spellcheck attribute was set on the ProseMirror editable surface. Add a spellCheck prop (default true) that sets spellcheck on the body editable surface and keeps it in sync on change. React threads it through DocxEditor -> DocxEditorPagedArea -> PagedEditor -> HiddenProseMirror (mirroring readOnly); Vue exposes it via a useDocxEditor option and the DocxEditor.vue prop. Body editable only; header and footer chrome are untouched. Includes the parity-contract entry, regenerated API snapshots, and a changeset. Closes #32 --- .changeset/spellcheck-support.md | 6 +++ docs/api/docx-editor-react/index.api.md | 1 + docs/api/docx-editor-vue/composables.api.md | 1 + docs/api/docx-editor-vue/index.api.md | 1 + packages/react/src/components/DocxEditor.tsx | 32 ++++++++-------- .../DocxEditor/DocxEditorPagedArea.tsx | 3 ++ .../DocxEditor/HiddenProseMirror.tsx | 18 +++++++++ .../src/components/DocxEditor/PagedEditor.tsx | 14 +++---- .../components/DocxEditor/spellcheck.test.tsx | 38 +++++++++++++++++++ packages/vue/src/components/DocxEditor.vue | 2 + .../vue/src/components/DocxEditor/types.ts | 2 + packages/vue/src/composables/useDocxEditor.ts | 22 +++++++++++ scripts/parity/parity.contract.json | 1 + 13 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 .changeset/spellcheck-support.md create mode 100644 packages/react/src/components/DocxEditor/spellcheck.test.tsx diff --git a/.changeset/spellcheck-support.md b/.changeset/spellcheck-support.md new file mode 100644 index 000000000..6cedad682 --- /dev/null +++ b/.changeset/spellcheck-support.md @@ -0,0 +1,6 @@ +--- +'@eigenpal/docx-editor-react': minor +'@eigenpal/docx-editor-vue': minor +--- + +Enable the browser-native spellchecker on the editor. A new `spellCheck` prop toggles it (default on). Fixes #32. diff --git a/docs/api/docx-editor-react/index.api.md b/docs/api/docx-editor-react/index.api.md index 6ce255de1..a0418f18d 100644 --- a/docs/api/docx-editor-react/index.api.md +++ b/docs/api/docx-editor-react/index.api.md @@ -110,6 +110,7 @@ export interface DocxEditorProps { showRuler?: boolean; showToolbar?: boolean; showZoomControl?: boolean; + spellCheck?: boolean; style?: CSSProperties; theme?: Theme | null; toolbarExtra?: ReactNode; diff --git a/docs/api/docx-editor-vue/composables.api.md b/docs/api/docx-editor-vue/composables.api.md index a54b64a1d..60b300a04 100644 --- a/docs/api/docx-editor-vue/composables.api.md +++ b/docs/api/docx-editor-vue/composables.api.md @@ -244,6 +244,7 @@ export interface UseDocxEditorOptions { pageGap?: number; pagesContainer: Ref; readOnly?: MaybeRef; + spellCheck?: MaybeRef; syncCoordinator?: LayoutSelectionGate; } diff --git a/docs/api/docx-editor-vue/index.api.md b/docs/api/docx-editor-vue/index.api.md index cd6480f64..1e2f575f6 100644 --- a/docs/api/docx-editor-vue/index.api.md +++ b/docs/api/docx-editor-vue/index.api.md @@ -90,6 +90,7 @@ export interface DocxEditorProps { showRuler?: boolean; showToolbar?: boolean; showZoomControl?: boolean; + spellCheck?: boolean; style?: StyleValue; theme?: Theme | null; toolbarExtra?: () => VNodeChild; diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index d358c2b5e..4a70aa4dd 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -176,6 +176,8 @@ export interface DocxEditorProps { initialZoom?: number; /** Whether the editor is read-only. When true, hides toolbar and rulers */ readOnly?: boolean; + /** Whether the browser-native spellchecker underlines misspellings on the editable surface (default: true) */ + spellCheck?: boolean; /** * When true, the editor does not intercept Cmd/Ctrl+F or Cmd/Ctrl+H. * This lets the browser or host app handle native find/history shortcuts. @@ -613,6 +615,7 @@ export const DocxEditor = forwardRef(function Do rulerUnit = 'inch', initialZoom = 1.0, readOnly: readOnlyProp = false, + spellCheck = true, disableFindReplaceShortcuts = false, toolbarExtra, className = '', @@ -947,12 +950,11 @@ export const DocxEditor = forwardRef(function Do focusActiveEditor, }); - // Mirror PM state on each external document load (mount-time view creation - // is handled by PagedEditor's `onReady` below; this effect catches subsequent - // loads via `document`/`documentBuffer` prop changes, which go through - // HiddenProseMirror's `updateState` and never fire `handleDocumentChange`). - // Effects run child-first, so `view.state` already reflects the new doc by - // the time this runs. + // Mirror PM state on each external document load (mount-time view creation is + // handled by PagedEditor's `onReady`; this effect catches subsequent loads via + // `document`/`documentBuffer` prop changes, which go through HiddenProseMirror's + // `updateState` and never fire `handleDocumentChange`). Effects run child-first, + // so `view.state` already reflects the new doc by the time this runs. useEffect(() => { if (state.isLoading || !history.state) return; const view = pagedEditorRef.current?.getView(); @@ -1024,9 +1026,8 @@ export const DocxEditor = forwardRef(function Do // Recompute the floating "add comment" button position from the current PM // selection + page/container geometry. Called from handleSelectionChange and - // from the geometry-change effects below (resize, zoom), because PagedEditor's - // onSelectionChange no longer fires on mere overlay redraws after the - // state-identity dedup in #268. + // the geometry-change effects below (resize, zoom), since PagedEditor's + // onSelectionChange no longer fires on overlay redraws after the #268 dedup. const { recomputeFloatingCommentBtn } = useFloatingCommentBtn({ pagedEditorRef, scrollContainerRef, @@ -1565,13 +1566,11 @@ export const DocxEditor = forwardRef(function Do return ids; }, [comments]); - // PagedEditor onSelectionChange — runs on every selection movement. - // Extracts the full selection state for the host callback, then walks the - // marks at the cursor to detect comment / tracked-change marks so the - // matching sidebar card opens. Comment marks are reported by either - // $from.marks() or by storedMarks/nodeBefore/nodeAfter at boundaries; the - // four sources get unioned. Resolved comments stay collapsed unless the - // user explicitly clicks them, so the sidebar doesn't fill with old + // PagedEditor onSelectionChange — runs on every selection movement. Extracts + // the full selection state for the host callback, then unions comment / + // tracked-change marks from $from.marks() and storedMarks/nodeBefore/nodeAfter + // (boundaries) so the matching sidebar card opens. Resolved comments stay + // collapsed unless explicitly clicked, so the sidebar doesn't fill with old // threads as the cursor sweeps through commented text. const handlePagedSelectionChange = useCallback(() => { const view = pagedEditorRef.current?.getView(); @@ -1859,6 +1858,7 @@ export const DocxEditor = forwardRef(function Do getHfTargetElement={getHfTargetElement} zoom={state.zoom} readOnly={readOnly} + spellCheck={spellCheck} isSuggesting={editingMode === 'suggesting'} author={author} onHfTransaction={(_rId, _view, docChanged) => { diff --git a/packages/react/src/components/DocxEditor/DocxEditorPagedArea.tsx b/packages/react/src/components/DocxEditor/DocxEditorPagedArea.tsx index 02bf91ffa..c2e9cb708 100644 --- a/packages/react/src/components/DocxEditor/DocxEditorPagedArea.tsx +++ b/packages/react/src/components/DocxEditor/DocxEditorPagedArea.tsx @@ -73,6 +73,7 @@ export function DocxEditorPagedArea({ // Editor zoom, readOnly, + spellCheck, extensionManager, externalPlugins, onDocumentChange, @@ -139,6 +140,7 @@ export function DocxEditorPagedArea({ getHfTargetElement: (pos: 'header' | 'footer') => HTMLElement | null; zoom: number; readOnly: boolean; + spellCheck: boolean; extensionManager: ExtensionManager; externalPlugins: Plugin[]; onDocumentChange: (doc: Document) => void; @@ -371,6 +373,7 @@ export function DocxEditorPagedArea({ // context menu) through the active-surface helper directly. zoom={zoom} readOnly={readOnly} + spellCheck={spellCheck} extensionManager={extensionManager} onDocumentChange={onDocumentChange} onSelectionChange={onPagedSelectionChange} diff --git a/packages/react/src/components/DocxEditor/HiddenProseMirror.tsx b/packages/react/src/components/DocxEditor/HiddenProseMirror.tsx index 11323fec3..898e015a0 100644 --- a/packages/react/src/components/DocxEditor/HiddenProseMirror.tsx +++ b/packages/react/src/components/DocxEditor/HiddenProseMirror.tsx @@ -60,6 +60,8 @@ export interface HiddenProseMirrorProps { widthPx?: number; /** Whether the editor is read-only */ readOnly?: boolean; + /** Whether the browser-native spellchecker is enabled on the editable surface (default: true) */ + spellCheck?: boolean; /** Callback when document changes via transaction */ onTransaction?: (transaction: Transaction, newState: EditorState) => void; /** Callback when selection changes */ @@ -211,6 +213,7 @@ const HiddenProseMirrorComponent = forwardRef !readOnlyRef.current, // Keeps `overflow-anchor` on the PM root across outer-deco sync (prosemirror#933). + // `spellcheck` enables the browser-native spellchecker on the editable + // surface; read through the ref so a later prop change is reflected by + // the effect below without re-mounting the view. attributes: { style: 'overflow-anchor: none', + spellcheck: spellCheckRef.current ? 'true' : 'false', }, // Use a regular function (not arrow) so ProseMirror's `.call(this, tr)` // binding gives us the EditorView. This is critical: plugins like ySyncPlugin @@ -408,6 +417,15 @@ const HiddenProseMirrorComponent = forwardRef { + const view = viewRef.current; + if (!view) return; + view.dom.setAttribute('spellcheck', spellCheck ? 'true' : 'false'); + }, [spellCheck]); + // ======================================================================== // Imperative Handle // ======================================================================== diff --git a/packages/react/src/components/DocxEditor/PagedEditor.tsx b/packages/react/src/components/DocxEditor/PagedEditor.tsx index b2e7efba7..57f863255 100644 --- a/packages/react/src/components/DocxEditor/PagedEditor.tsx +++ b/packages/react/src/components/DocxEditor/PagedEditor.tsx @@ -102,6 +102,8 @@ export interface PagedEditorProps { firstPageFooterContent?: HeaderFooter | null; /** Whether the editor is read-only. */ readOnly?: boolean; + /** Whether the browser-native spellchecker is enabled (default: true). */ + spellCheck?: boolean; /** Gap between pages in pixels. */ pageGap?: number; /** Zoom level (1 = 100%). */ @@ -272,11 +274,6 @@ export interface PagedEditorRef { getHfPmViews(): Map; } -// ============================================================================= -// HELPER FUNCTIONS -// ============================================================================= -// Module-scope helpers extracted to per-domain files — see top of file -// for the import block. // ============================================================================= // COMPONENT // ============================================================================= @@ -297,6 +294,7 @@ const PagedEditorComponent = forwardRef( firstPageHeaderContent, firstPageFooterContent, readOnly = false, + spellCheck = true, pageGap = DEFAULT_PAGE_GAP, zoom = 1, onDocumentChange, @@ -824,16 +822,14 @@ const PagedEditorComponent = forwardRef( onKeyDown={handleKeyDown} onMouseDown={handleContainerMouseDown} > - {/* Hidden ProseMirror for keyboard input */} + {/* Hidden ProseMirror for keyboard input. In HF mode the body PM is retired — readOnly stops input from reaching the body doc if focus slips to it. */} GlobalRegistrator.register()); +afterAll(() => GlobalRegistrator.unregister()); + +import { afterEach, describe, expect, test } from 'bun:test'; +import { cleanup, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { createEmptyDocument } from '@eigenpal/docx-editor-core'; +import { HiddenProseMirror, type HiddenProseMirrorRef } from './HiddenProseMirror'; + +afterEach(() => { + cleanup(); +}); + +function renderEditor(props: { spellCheck?: boolean }) { + const ref = createRef(); + render(); + const view = ref.current?.getView(); + if (!view) throw new Error('EditorView did not mount'); + return view.dom as HTMLElement; +} + +describe('HiddenProseMirror spellcheck', () => { + test('enables the browser-native spellchecker by default', () => { + const dom = renderEditor({}); + expect(dom.getAttribute('spellcheck')).toBe('true'); + }); + + test('disables the spellchecker when spellCheck is false', () => { + const dom = renderEditor({ spellCheck: false }); + expect(dom.getAttribute('spellcheck')).toBe('false'); + }); +}); diff --git a/packages/vue/src/components/DocxEditor.vue b/packages/vue/src/components/DocxEditor.vue index 203a2770c..c618731dd 100644 --- a/packages/vue/src/components/DocxEditor.vue +++ b/packages/vue/src/components/DocxEditor.vue @@ -455,6 +455,7 @@ const props = withDefaults(defineProps(), { showRuler: true, documentName: '', readOnly: false, + spellCheck: true, author: 'User', mode: 'editing', // Explicit `undefined` default opts out of Vue's absent-Boolean-prop cast to @@ -573,6 +574,7 @@ const { hiddenContainer: hiddenPmRef, pagesContainer: pagesRef, readOnly, + spellCheck: computed(() => props.spellCheck), externalPlugins: props.externalPlugins, syncCoordinator, editorMode, diff --git a/packages/vue/src/components/DocxEditor/types.ts b/packages/vue/src/components/DocxEditor/types.ts index c399611ce..e4fc2015f 100644 --- a/packages/vue/src/components/DocxEditor/types.ts +++ b/packages/vue/src/components/DocxEditor/types.ts @@ -50,6 +50,8 @@ export interface DocxEditorProps { documentName?: string; /** Whether the editor is read-only. */ readOnly?: boolean; + /** Whether the browser-native spellchecker underlines misspellings on the editable surface (default: true). */ + spellCheck?: boolean; /** Author name used for comments and tracked changes created in the UI. Defaults to `'User'`. */ author?: string; /** Editor mode: direct editing, suggesting, or viewing. */ diff --git a/packages/vue/src/composables/useDocxEditor.ts b/packages/vue/src/composables/useDocxEditor.ts index 464a22287..4514510bf 100644 --- a/packages/vue/src/composables/useDocxEditor.ts +++ b/packages/vue/src/composables/useDocxEditor.ts @@ -191,6 +191,8 @@ export interface UseDocxEditorOptions { pagesContainer: Ref; /** Whether the editor is read-only */ readOnly?: MaybeRef; + /** Whether the browser-native spellchecker is enabled on the editable surface (default: true) */ + spellCheck?: MaybeRef; /** Page gap in pixels */ pageGap?: number; /** Callback on document change */ @@ -282,6 +284,7 @@ export function useDocxEditor(options: UseDocxEditorOptions): UseDocxEditorRetur hiddenContainer, pagesContainer, readOnly = false, + spellCheck = true, pageGap = DEFAULT_PAGE_GAP, onChange, onError, @@ -496,6 +499,12 @@ export function useDocxEditor(options: UseDocxEditorOptions): UseDocxEditorRetur const view = new EditorView(host, { state, editable: () => !unref(readOnly), + // `spellcheck` enables the browser-native spellchecker on the editable + // surface. Seeded here at construction; the watcher below keeps it in + // sync when the prop toggles without re-mounting the view. + attributes: { + spellcheck: unref(spellCheck) ? 'true' : 'false', + }, dispatchTransaction(transaction: Transaction) { if (!view) return; // Paginated painter owns scroll; strip PM's scroll flag so updateState @@ -766,6 +775,19 @@ export function useDocxEditor(options: UseDocxEditorOptions): UseDocxEditorRetur { immediate: true } ); + // Reflect spellCheck prop changes onto the live editable surface. The + // `attributes` prop seeds the value at construction; this keeps it in sync + // when the prop toggles without re-mounting the view. + watch( + [() => unref(spellCheck), editorView], + ([enabled, view]) => { + if (view) { + view.dom.setAttribute('spellcheck', enabled ? 'true' : 'false'); + } + }, + { immediate: true } + ); + // Listener slot — DocxEditor.vue subscribes here to update caret + UI // chrome on every HF transaction. Held in a ref so swapping it doesn't // require resetting the `dispatchTransaction` closure on each EditorView. diff --git a/scripts/parity/parity.contract.json b/scripts/parity/parity.contract.json index 303cbe3fd..518c88fe8 100644 --- a/scripts/parity/parity.contract.json +++ b/scripts/parity/parity.contract.json @@ -42,6 +42,7 @@ "showRuler", "showToolbar", "showZoomControl", + "spellCheck", "style", "theme", "toolbarExtra",