diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js index 28d6796901..7745b50833 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js @@ -47,24 +47,27 @@ vi.mock('prosemirror-tables', () => ({ })), })); +import { selectedRect as selectedRectMock } from 'prosemirror-tables'; +import { undoDepth, redoDepth } from 'prosemirror-history'; +import { yUndoPluginKey } from 'y-prosemirror'; + +import { isList } from '@core/commands/list-helpers'; +import { isCellSelection as isCellSelectionMock } from '@extensions/table/tableHelpers/isCellSelection.js'; +import { + collectTrackedChanges, + collectTrackedChangesForContext, +} from '@extensions/track-changes/permission-helpers.js'; + import { getEditorContext, getPropsByItemId, __getStructureFromResolvedPosForTest, __isCollaborationEnabledForTest, __getCellSelectionInfoForTest, + __resolveProofingContextForTest, } from '../utils.js'; -import { isList } from '@core/commands/list-helpers'; import { readFromClipboard } from '../../../core/utilities/clipboardUtils.js'; import { selectionHasNodeOrMark } from '../../cursor-helpers.js'; -import { undoDepth, redoDepth } from 'prosemirror-history'; -import { yUndoPluginKey } from 'y-prosemirror'; -import { isCellSelection as isCellSelectionMock } from '@extensions/table/tableHelpers/isCellSelection.js'; -import { selectedRect as selectedRectMock } from 'prosemirror-tables'; -import { - collectTrackedChanges, - collectTrackedChangesForContext, -} from '@extensions/track-changes/permission-helpers.js'; // Get the mocked functions const mockReadFromClipboard = vi.mocked(readFromClipboard); @@ -749,4 +752,138 @@ describe('utils.js', () => { expect(__isCollaborationEnabledForTest({ options: {} })).toBe(false); }); }); + + // SD-2875: spelling suggestions vanished from the right-click menu in 1.29 + // because moved from the inner Editor to the PresentationEditor + // wrapper, which doesn't carry _presentationEditor. + describe('resolveProofingContext (SD-2875 regression)', () => { + const buildIssue = () => ({ + pmFrom: 10, + pmTo: 13, + word: 'teh', + replacements: ['the', 'tech', 'meh'], + }); + + const buildManager = (issue) => { + const ignoreWord = vi.fn(); + return { + manager: { + getIssueAtPosition: vi.fn(() => issue), + config: { maxSuggestions: 5, allowIgnoreWord: true }, + ignoreWord, + }, + ignoreWord, + }; + }; + + it('reads the manager from the inner editor back-reference (1.28 wiring)', () => { + const issue = buildIssue(); + const { manager } = buildManager(issue); + const innerEditor = { _presentationEditor: { proofingManager: manager } }; + + const ctx = __resolveProofingContextForTest(innerEditor, 11); + + expect(manager.getIssueAtPosition).toHaveBeenCalledWith(11); + expect(ctx).toMatchObject({ + issue, + word: 'teh', + canIgnore: true, + suggestions: ['the', 'tech', 'meh'], + }); + }); + + it('reads the manager from the PresentationEditor wrapper directly (1.29+ wiring)', () => { + const issue = buildIssue(); + const { manager } = buildManager(issue); + // The wrapper exposes proofingManager as its own property and has no + // _presentationEditor / presentationEditor back-reference. Before the + // SD-2875 fix this path returned null and the menu silently dropped + // every spelling suggestion. + const wrapper = { proofingManager: manager }; + + const ctx = __resolveProofingContextForTest(wrapper, 11); + + expect(ctx).not.toBeNull(); + expect(ctx.issue).toBe(issue); + }); + + it('reads the manager from a story editor (presentationEditor field)', () => { + const issue = buildIssue(); + const { manager } = buildManager(issue); + const storyEditor = { presentationEditor: { proofingManager: manager } }; + + const ctx = __resolveProofingContextForTest(storyEditor, 11); + + expect(ctx).not.toBeNull(); + expect(ctx.issue).toBe(issue); + }); + + it('returns null when no editor handle exposes a proofing manager', () => { + const plainEditor = { view: {} }; + expect(__resolveProofingContextForTest(plainEditor, 11)).toBeNull(); + }); + + it('returns null when the position is invalid', () => { + const { manager } = buildManager(buildIssue()); + const wrapper = { proofingManager: manager }; + + expect(__resolveProofingContextForTest(wrapper, null)).toBeNull(); + expect(__resolveProofingContextForTest(wrapper, NaN)).toBeNull(); + expect(manager.getIssueAtPosition).not.toHaveBeenCalled(); + }); + + it('returns null when the manager has no issue at the position', () => { + const manager = { + getIssueAtPosition: vi.fn(() => null), + config: { maxSuggestions: 5, allowIgnoreWord: true }, + ignoreWord: vi.fn(), + }; + const wrapper = { proofingManager: manager }; + + expect(__resolveProofingContextForTest(wrapper, 11)).toBeNull(); + }); + + it('clamps suggestions to maxSuggestions and routes ignoreWord through the manager', () => { + const issue = { ...buildIssue(), replacements: ['a', 'b', 'c', 'd', 'e', 'f'] }; + const { manager, ignoreWord } = buildManager(issue); + manager.config = { maxSuggestions: 3, allowIgnoreWord: true }; + const wrapper = { proofingManager: manager }; + + const ctx = __resolveProofingContextForTest(wrapper, 11); + + expect(ctx.suggestions).toEqual(['a', 'b', 'c']); + ctx.ignoreWord('teh'); + expect(ignoreWord).toHaveBeenCalledWith('teh'); + }); + + it('getEditorContext propagates proofingContext when editor is the PresentationEditor wrapper', async () => { + const issue = buildIssue(); + const { manager } = buildManager(issue); + + // Simulate the 1.29+ wiring: the editor passed to is the + // PresentationEditor wrapper. It exposes view/state/posAtCoords like + // the inner Editor and exposes proofingManager directly. + const wrapperEditor = { + ...mockEditor, + proofingManager: manager, + }; + // No back-references — pre-fix this path produced proofingContext: null. + delete wrapperEditor._presentationEditor; + delete wrapperEditor.presentationEditor; + + wrapperEditor.view.posAtCoords.mockReturnValue({ pos: 11 }); + wrapperEditor.view.state.doc.nodeAt.mockReturnValue({ type: { name: 'text' } }); + wrapperEditor.view.state.doc.resolve.mockReturnValue({ + marks: vi.fn(() => []), + nodeBefore: null, + nodeAfter: null, + }); + + const context = await getEditorContext(wrapperEditor, { clientX: 50, clientY: 60 }); + + expect(context.proofingContext).not.toBeNull(); + expect(context.proofingContext.word).toBe('teh'); + expect(context.proofingContext.suggestions).toEqual(['the', 'tech', 'meh']); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index 04cdc09228..25421c0c03 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -416,11 +416,17 @@ function resolveProofingContext(editor, pos) { if (pos == null || !Number.isFinite(pos)) return null; try { - // Access PresentationEditor's proofing manager via the editor's back-reference - const pe = editor?._presentationEditor; - if (!pe?.proofingManager) return null; - - const manager = pe.proofingManager; + // The context menu is wired to either the PresentationEditor wrapper + // (since SD-2875: 1.29+) or the inner / story Editor that carries a + // back-reference to it. Resolve the manager from whichever shape the + // caller passed — without this fallback, suggestions silently vanish + // when the wrapper itself is the menu's editor handle. + const manager = + editor?._presentationEditor?.proofingManager ?? + editor?.presentationEditor?.proofingManager ?? + editor?.proofingManager ?? + null; + if (!manager) return null; const issue = manager.getIssueAtPosition(pos); if (!issue) return null; @@ -443,4 +449,5 @@ export { getStructureFromResolvedPos as __getStructureFromResolvedPosForTest, isCollaborationEnabled as __isCollaborationEnabledForTest, getCellSelectionInfo as __getCellSelectionInfoForTest, + resolveProofingContext as __resolveProofingContextForTest, }; diff --git a/tests/behavior/tests/slash-menu/proofing-context-menu.spec.ts b/tests/behavior/tests/slash-menu/proofing-context-menu.spec.ts new file mode 100644 index 0000000000..17f31439b0 --- /dev/null +++ b/tests/behavior/tests/slash-menu/proofing-context-menu.spec.ts @@ -0,0 +1,216 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +/** + * SD-2875 — Right-clicking a misspelled word must show provider replacements + * in the context menu. In 1.29 the wiring between and the + * proofing manager broke (resolveProofingContext could not find the manager + * when the menu's editor handle was the PresentationEditor wrapper instead + * of the inner Editor). This test reproduces the customer-reported flow: + * type "teh", attach a stub provider that flags it, right-click the word, + * and assert the suggestions appear and replace the word when clicked. + */ + +type StubIssue = { + segmentId: string; + start: number; + end: number; + kind: 'spelling'; + replacements: string[]; +}; + +declare global { + interface Window { + __sd2875Calls?: number; + } +} + +async function configureStubProvider( + superdoc: { page: import('@playwright/test').Page }, + word: string, + replacements: string[], +): Promise { + await superdoc.page.evaluate( + ({ misspelled, repls }) => { + window.__sd2875Calls = 0; + const stubProvider = { + id: 'sd-2875-stub', + getCapabilities: () => ({ + issueKinds: ['spelling'], + supportsSuggestions: true, + }), + check: async ({ segments }: { segments: Array<{ id: string; text: string }> }) => { + window.__sd2875Calls = (window.__sd2875Calls ?? 0) + 1; + const issues: StubIssue[] = []; + for (const seg of segments) { + let from = 0; + while (from <= seg.text.length) { + const i = seg.text.indexOf(misspelled, from); + if (i === -1) break; + issues.push({ + segmentId: seg.id, + start: i, + end: i + misspelled.length, + kind: 'spelling', + replacements: repls, + }); + from = i + misspelled.length; + } + } + return { issues }; + }, + }; + + const editor = (window as unknown as { editor?: { presentationEditor?: unknown } }).editor; + const pe = editor?.presentationEditor as + | { + updateProofingConfig: (patch: Record) => void; + } + | undefined; + if (!pe?.updateProofingConfig) { + throw new Error('SD-2875 test: no PresentationEditor.updateProofingConfig found on window.editor'); + } + + pe.updateProofingConfig({ + enabled: true, + provider: stubProvider, + defaultLanguage: 'en_US', + // Keep debounce short so the test does not stall waiting for + // provider scheduling — we only care about the wiring, not the + // throttling. + debounceMs: 50, + maxSuggestions: 5, + allowIgnoreWord: true, + }); + }, + { misspelled: word, repls: replacements }, + ); +} + +async function waitForProofingIssue(superdoc: { page: import('@playwright/test').Page }, timeout = 10_000) { + await superdoc.page.waitForFunction( + () => { + const editor = (window as unknown as { editor?: { presentationEditor?: unknown } }).editor; + const pe = editor?.presentationEditor as + | { + proofingManager?: { + getPaintSlices?: () => Array<{ pmFrom: number; pmTo: number }>; + } | null; + } + | undefined; + const slices = pe?.proofingManager?.getPaintSlices?.() ?? []; + return slices.length > 0; + }, + null, + { timeout, polling: 50 }, + ); +} + +async function rightClickAtPmPos(superdoc: { page: import('@playwright/test').Page }, pos: number): Promise { + const coords = await superdoc.page.evaluate((p: number) => { + const editor = ( + window as unknown as { + editor?: { + presentationEditor?: { + coordsAtPos?: (pos: number) => { top: number; bottom: number; left: number; right: number } | null; + }; + }; + } + ).editor; + const c = editor?.presentationEditor?.coordsAtPos?.(p) ?? null; + if (!c) return null; + // Aim a couple of pixels into the run rather than at its left edge so + // posAtCoords resolves a position inside (not at the boundary of) the + // misspelled word. + return { x: c.left + 2, y: (c.top + c.bottom) / 2 }; + }, pos); + + if (!coords) { + throw new Error(`SD-2875 test: coordsAtPos returned null for pmPos ${pos}`); + } + + await superdoc.page.mouse.click(coords.x, coords.y, { button: 'right' }); +} + +test('right-click on a misspelled word shows provider suggestions in the context menu (SD-2875)', async ({ + superdoc, +}) => { + const { page } = superdoc; + + await superdoc.type('Hello teh world'); + await superdoc.waitForStable(); + + await configureStubProvider(superdoc, 'teh', ['the', 'tech', 'meh']); + + // Wait until the proofing manager has stored an issue for 'teh'. Without + // this, racing the right-click before the provider has returned can mask + // a regression as a flaky timing issue. + await waitForProofingIssue(superdoc); + + // Aim the right-click at the middle of the misspelled word so + // posAtCoords lands inside the issue range. + const tehPos = await superdoc.findTextPos('teh'); + await rightClickAtPmPos(superdoc, tehPos + 1); + await superdoc.waitForStable(); + + // The context menu must open and surface the provider replacements as + // clickable rows. Pre-fix (1.29+) only the generic actions appeared. + const menu = page.locator('.context-menu'); + await expect(menu).toBeVisible(); + + const items = menu.locator('.context-menu-item'); + await expect(items.filter({ hasText: /^the$/ })).toBeVisible(); + await expect(items.filter({ hasText: /^tech$/ })).toBeVisible(); + await expect(items.filter({ hasText: /^meh$/ })).toBeVisible(); + + // Clicking a suggestion must apply it to the document — confirms the + // action callback wires through to the live editor view. + await items.filter({ hasText: /^the$/ }).first().click(); + await superdoc.waitForStable(); + + await expect(menu).toBeHidden(); + + const text = await page.evaluate(() => { + const editor = ( + window as unknown as { + editor?: { + state?: { doc?: { textBetween: (a: number, b: number, sep: string) => string; content: { size: number } } }; + }; + } + ).editor; + const doc = editor?.state?.doc; + if (!doc) return null; + return doc.textBetween(0, doc.content.size, '\n'); + }); + expect(text).toContain('Hello the world'); + expect(text).not.toContain('teh'); +}); + +test('right-click on a correctly spelled word does NOT add proofing items (SD-2875)', async ({ superdoc }) => { + const { page } = superdoc; + + await superdoc.type('Hello world'); + await superdoc.waitForStable(); + + // Configure proofing with a provider that flags the word 'teh' (which is + // not present in the document). This guarantees the manager is wired + // up but has no issue at any position. + await configureStubProvider(superdoc, 'teh', ['the']); + + // Wait until the stub has actually run, otherwise this test can pass + // because the check never fired rather than because nothing matched. + await page.waitForFunction(() => (window.__sd2875Calls ?? 0) > 0, null, { timeout: 5_000 }); + await superdoc.waitForStable(); + + const helloPos = await superdoc.findTextPos('Hello'); + await rightClickAtPmPos(superdoc, helloPos + 2); + await superdoc.waitForStable(); + + const menu = page.locator('.context-menu'); + await expect(menu).toBeVisible(); + + // No proofing-replace rows should appear when there is no issue at + // the cursor; the menu should still surface the regular actions. + await expect(menu.locator('[id*="proofing-replace"]')).toHaveCount(0); +});