Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
__getStructureFromResolvedPosForTest,
__isCollaborationEnabledForTest,
__getCellSelectionInfoForTest,
__resolveProofingContextForTest,
} from '../utils.js';
import { isList } from '@core/commands/list-helpers';
import { readFromClipboard } from '../../../core/utilities/clipboardUtils.js';
Expand Down Expand Up @@ -749,4 +750,140 @@ describe('utils.js', () => {
expect(__isCollaborationEnabledForTest({ options: {} })).toBe(false);
});
});

// SD-2875: spelling suggestions vanished from the right-click menu in 1.29
// because <ContextMenu> was rebound from the inner Editor (which carries
// _presentationEditor) to the PresentationEditor wrapper (which does not).
// resolveProofingContext must locate the proofing manager from any of the
// three handles the menu can be wired to.
Comment thread
chittolinag marked this conversation as resolved.
Outdated
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 <ContextMenu> 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']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -443,4 +449,5 @@ export {
getStructureFromResolvedPos as __getStructureFromResolvedPosForTest,
isCollaborationEnabled as __isCollaborationEnabledForTest,
getCellSelectionInfo as __getCellSelectionInfoForTest,
resolveProofingContext as __resolveProofingContextForTest,
};
217 changes: 217 additions & 0 deletions tests/behavior/tests/slash-menu/proofing-context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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 <ContextMenu> 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<void> {
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<string, unknown>) => 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<void> {
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']);

// Give the provider a moment to run; we deliberately do NOT wait for
// an issue because none should be produced. A short stable settle is
// enough since the debounce is 50ms.
await page.waitForTimeout(150);
await superdoc.waitForStable();
Comment thread
chittolinag marked this conversation as resolved.
Outdated

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);
});
Loading