Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/spellcheck-support.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/api/docx-editor-react/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export interface DocxEditorProps {
showRuler?: boolean;
showToolbar?: boolean;
showZoomControl?: boolean;
spellCheck?: boolean;
style?: CSSProperties;
theme?: Theme | null;
toolbarExtra?: ReactNode;
Expand Down
1 change: 1 addition & 0 deletions docs/api/docx-editor-vue/composables.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export interface UseDocxEditorOptions {
pageGap?: number;
pagesContainer: Ref<HTMLElement | null>;
readOnly?: MaybeRef<boolean>;
spellCheck?: MaybeRef<boolean>;
syncCoordinator?: LayoutSelectionGate;
}

Expand Down
1 change: 1 addition & 0 deletions docs/api/docx-editor-vue/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface DocxEditorProps {
showRuler?: boolean;
showToolbar?: boolean;
showZoomControl?: boolean;
spellCheck?: boolean;
style?: StyleValue;
theme?: Theme | null;
toolbarExtra?: () => VNodeChild;
Expand Down
32 changes: 16 additions & 16 deletions packages/react/src/components/DocxEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -613,6 +615,7 @@ export const DocxEditor = forwardRef<DocxEditorRef, DocxEditorProps>(function Do
rulerUnit = 'inch',
initialZoom = 1.0,
readOnly: readOnlyProp = false,
spellCheck = true,
disableFindReplaceShortcuts = false,
toolbarExtra,
className = '',
Expand Down Expand Up @@ -947,12 +950,11 @@ export const DocxEditor = forwardRef<DocxEditorRef, DocxEditorProps>(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();
Expand Down Expand Up @@ -1024,9 +1026,8 @@ export const DocxEditor = forwardRef<DocxEditorRef, DocxEditorProps>(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,
Expand Down Expand Up @@ -1565,13 +1566,11 @@ export const DocxEditor = forwardRef<DocxEditorRef, DocxEditorProps>(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();
Expand Down Expand Up @@ -1859,6 +1858,7 @@ export const DocxEditor = forwardRef<DocxEditorRef, DocxEditorProps>(function Do
getHfTargetElement={getHfTargetElement}
zoom={state.zoom}
readOnly={readOnly}
spellCheck={spellCheck}
isSuggesting={editingMode === 'suggesting'}
author={author}
onHfTransaction={(_rId, _view, docChanged) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function DocxEditorPagedArea({
// Editor
zoom,
readOnly,
spellCheck,
extensionManager,
externalPlugins,
onDocumentChange,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/components/DocxEditor/HiddenProseMirror.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -211,6 +213,7 @@ const HiddenProseMirrorComponent = forwardRef<HiddenProseMirrorRef, HiddenProseM
theme: _theme,
widthPx = 612, // Default Letter width at 72dpi
readOnly = false,
spellCheck = true,
onTransaction,
onSelectionChange,
externalPlugins = [],
Expand Down Expand Up @@ -239,6 +242,7 @@ const HiddenProseMirrorComponent = forwardRef<HiddenProseMirrorRef, HiddenProseM
const onEditorViewDestroyRef = useRef(onEditorViewDestroy);
const onKeyDownRef = useRef(onKeyDown);
const readOnlyRef = useRef(readOnly);
const spellCheckRef = useRef(spellCheck);

// Keep refs in sync
onTransactionRef.current = onTransaction;
Expand All @@ -247,6 +251,7 @@ const HiddenProseMirrorComponent = forwardRef<HiddenProseMirrorRef, HiddenProseM
onEditorViewDestroyRef.current = onEditorViewDestroy;
onKeyDownRef.current = onKeyDown;
readOnlyRef.current = readOnly;
spellCheckRef.current = spellCheck;

// Keep document ref in sync
documentRef.current = document;
Expand Down Expand Up @@ -279,8 +284,12 @@ const HiddenProseMirrorComponent = forwardRef<HiddenProseMirrorRef, HiddenProseM
// PM calls `editable()` on every input check.
editable: () => !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
Expand Down Expand Up @@ -408,6 +417,15 @@ const HiddenProseMirrorComponent = forwardRef<HiddenProseMirrorRef, HiddenProseM
// EditorView will call editable() on each check, so we don't need to update
}, [readOnly]);

// 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.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dom.setAttribute('spellcheck', spellCheck ? 'true' : 'false');
}, [spellCheck]);

// ========================================================================
// Imperative Handle
// ========================================================================
Expand Down
14 changes: 5 additions & 9 deletions packages/react/src/components/DocxEditor/PagedEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%). */
Expand Down Expand Up @@ -272,11 +274,6 @@ export interface PagedEditorRef {
getHfPmViews(): Map<string, EditorView>;
}

// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// Module-scope helpers extracted to per-domain files — see top of file
// for the import block.
// =============================================================================
// COMPONENT
// =============================================================================
Expand All @@ -297,6 +294,7 @@ const PagedEditorComponent = forwardRef<PagedEditorRef, PagedEditorProps>(
firstPageHeaderContent,
firstPageFooterContent,
readOnly = false,
spellCheck = true,
pageGap = DEFAULT_PAGE_GAP,
zoom = 1,
onDocumentChange,
Expand Down Expand Up @@ -824,16 +822,14 @@ const PagedEditorComponent = forwardRef<PagedEditorRef, PagedEditorProps>(
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. */}
<HiddenProseMirror
ref={hiddenPMRef}
document={document}
styles={styles}
widthPx={contentWidth}
// When HF mode is active, the body PM is functionally retired —
// marking it readOnly stops keystrokes / input events from being
// applied to the body doc even if focus briefly slips to it.
readOnly={readOnly || !!hfEditMode}
spellCheck={spellCheck}
onTransaction={handleTransaction}
onSelectionChange={handleSelectionChange}
externalPlugins={externalPlugins}
Expand Down
38 changes: 38 additions & 0 deletions packages/react/src/components/DocxEditor/spellcheck.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator';
import { afterAll, beforeAll } from 'bun:test';

// Register happy-dom for this file and unregister after, matching the rest of
// the suite. A load-time register that never unregisters leaks the global
// registration across files and collides with other files' setup.
beforeAll(() => 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<HiddenProseMirrorRef>();
render(<HiddenProseMirror ref={ref} document={createEmptyDocument()} {...props} />);
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');
});
});
Comment on lines +28 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for dynamic prop-change

The suite only checks the attribute at initial render; the useEffect(() => { view.dom.setAttribute(...) }, [spellCheck]) path — which is the one that actually matters when a host app toggles the prop at runtime — is never exercised. If that effect were accidentally removed or had a stale closure, both static tests would still pass. A third test that renders with spellCheck={true}, then re-renders with spellCheck={false} (via rerender from RTL) and re-reads dom.getAttribute('spellcheck') would cover the dynamic case.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

2 changes: 2 additions & 0 deletions packages/vue/src/components/DocxEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ const props = withDefaults(defineProps<DocxEditorProps>(), {
showRuler: true,
documentName: '',
readOnly: false,
spellCheck: true,
author: 'User',
mode: 'editing',
// Explicit `undefined` default opts out of Vue's absent-Boolean-prop cast to
Expand Down Expand Up @@ -573,6 +574,7 @@ const {
hiddenContainer: hiddenPmRef,
pagesContainer: pagesRef,
readOnly,
spellCheck: computed(() => props.spellCheck),
externalPlugins: props.externalPlugins,
syncCoordinator,
editorMode,
Expand Down
2 changes: 2 additions & 0 deletions packages/vue/src/components/DocxEditor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
22 changes: 22 additions & 0 deletions packages/vue/src/composables/useDocxEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export interface UseDocxEditorOptions {
pagesContainer: Ref<HTMLElement | null>;
/** Whether the editor is read-only */
readOnly?: MaybeRef<boolean>;
/** Whether the browser-native spellchecker is enabled on the editable surface (default: true) */
spellCheck?: MaybeRef<boolean>;
/** Page gap in pixels */
pageGap?: number;
/** Callback on document change */
Expand Down Expand Up @@ -282,6 +284,7 @@ export function useDocxEditor(options: UseDocxEditorOptions): UseDocxEditorRetur
hiddenContainer,
pagesContainer,
readOnly = false,
spellCheck = true,
pageGap = DEFAULT_PAGE_GAP,
onChange,
onError,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions scripts/parity/parity.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"showRuler",
"showToolbar",
"showZoomControl",
"spellCheck",
"style",
"theme",
"toolbarExtra",
Expand Down
Loading