Skip to content
Merged
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
19 changes: 19 additions & 0 deletions src/editing/CanvasEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,24 @@ export class CanvasEditor {
}
}

private refreshListMarkers() {
if (!this.root) return;
const grouped = new Map<string, Element[]>();
for (const block of canvasParagraphs(this.root)) {
const markers = Array.from(block.querySelectorAll('[data-list-marker]')).filter(marker => !marker.querySelector('[data-list-marker]'));
if (!markers.length) continue;
const id = block.dataset.sourceAnchorId!;
if (!grouped.has(id) && this.controller.read(session => session.getListMembership(id)?.format) === 'bullet') continue;
grouped.set(id, [...grouped.get(id) ?? [], ...markers]);
}
const labels = this.controller.getListLabels([...grouped.keys()]);
for (const [id, markers] of grouped) {
const label = labels[id];
// Keep marker wrappers, tab spacing, run styling, and page fragments intact.
if (label !== undefined) for (const marker of markers) if (marker.textContent !== label) marker.textContent = label;
}
}

/** Execute structural edits as one undo step, then restore the editing caret. */
private mutate(action: string, operation: (session: DocxSession) => { results: EditResult[]; point: CanvasPoint; changed: string[]; removed?: string[] }, atomic = true) {
if (this.callbacks?.readOnly || !this.beforeCommand()) return false;
Expand All @@ -368,6 +386,7 @@ export class CanvasEditor {
this.range = collapsed(outcome.point); this.restoreFocus = true;
outcome.removed?.forEach(id => this.root && canvasParagraphs(this.root, id).forEach(block => block.remove()));
this.patchMany(outcome.changed);
if (outcome.results.some(result => result.created.length || result.removed.length)) this.refreshListMarkers();
this.restore(); this.notifySelection();
return true;
} catch (cause) { return this.fail(cause); }
Expand Down
11 changes: 11 additions & 0 deletions src/nativeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ export function openNativeSession(bytes: Uint8Array, settings: DocxSessionSettin
const result = JSON.parse(bridge.RenderEditorBlocksHtml(handle, JSON.stringify(ids), JSON.stringify(options)));
return result.error ? null : result;
},
listLabels: (ids: readonly string[]): Record<string, string | undefined> => {
// 12.6.2 retains list-counter annotations after a split/merge. A read-only
// snapshot recomputes Word numbering without changing live history or settings.
const fresh = new DocxSession(bridge.OpenSession(bridge.SaveWithAnchorIds(handle), JSON.stringify({ captureInitialProjection: false, emitMarkdownPatch: false })), bridge);
try {
return Object.fromEntries(ids.map(id => {
const list = fresh.getListMembership(id);
return [id, list?.format === 'bullet' ? undefined : list?.generatedLabel];
}));
} finally { fresh.close(); }
},
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class NativeSession {
}

function nativeBridge(session: NativeSession): bridge.NativeSession {
return { session: session as unknown as DocxSession, anchorIndex: () => ({}), renderBlocks: () => null };
return { session: session as unknown as DocxSession, anchorIndex: () => ({}), renderBlocks: () => null, listLabels: () => ({}) };
}

afterEach(() => vi.restoreAllMocks());
Expand Down
6 changes: 6 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,12 @@ export class DocxSessionController {
return this.bridge.renderBlocks(ids, options);
}

/** Recompute generated list labels after structural edits without stale native counters. */
getListLabels(ids: readonly string[]) {
if (!this.bridge) throw new Error('Open a document session first.');
return ids.length ? this.bridge.listLabels(ids) : {};
}

/** Read-only selectors run without emitting mutation notifications. */
read<T>(selector: (session: DocxSession) => T): T {
if (!this.native) throw new Error('Open a document session first.');
Expand Down
69 changes: 69 additions & 0 deletions tests/browser/canvas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ test('Enter splits at the caret, Backspace joins, and undo/redo restore native p
expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
});

for (const action of ['paste beside a numbered list', 'split a bullet item'] as const) test(`${action} avoids a numbering snapshot`, async ({ page }) => {
await open(page, 'List item.');
await page.evaluate(action => window.editorTest.controllers[0].run(s => {
const first = window.editorTest.anchor;
s.insertParagraph(first, 'after', 'Plain paragraph.');
s.applyListFormat(first, action.startsWith('paste') ? 'decimal' : 'bullet');
}), action);
await expect(paragraphs(page)).toHaveCount(2);
await expect(paragraphs(page).first().locator('[data-list-marker]').first()).toBeVisible();
await settled(page);
await paragraphs(page).nth(action.startsWith('paste') ? 1 : 0).click();
await page.keyboard.press('End');
await page.evaluate(() => {
const bridge = window.rdv.getWasmExports().DocxSessionBridge, save = bridge.SaveWithAnchorIds;
Reflect.set(window, 'numberingSnapshots', 0);
bridge.SaveWithAnchorIds = handle => {
Reflect.set(window, 'numberingSnapshots', Reflect.get(window, 'numberingSnapshots') + 1);
return save(handle);
};
});
if (action.startsWith('paste')) {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.evaluate(() => navigator.clipboard.writeText(' Pasted.'));
await page.keyboard.press('Control+v');
await expect.poll(() => nativeText(page)).toEqual(['List item.', 'Plain paragraph. Pasted.']);
} else {
await page.keyboard.press('Enter');
await expect.poll(() => nativeText(page)).toEqual(['List item.', '', 'Plain paragraph.']);
}
expect(await page.evaluate(() => Reflect.get(window, 'numberingSnapshots'))).toBe(0);
expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
});

test('collapsed Enter uses one native undo unit and one batch render', async ({ page }) => {
await open(page, 'Hello world.');
await paragraphs(page).first().click();
Expand Down Expand Up @@ -458,6 +491,42 @@ test('select-all formats body paragraphs without changing their interleaved foot
expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
});

for (const sample of [
{ name: 'decimal with a separate restart', format: 'decimal', start: 1, restart: 10, before: ['1.', '2.', '10.'], after: ['1.', '2.', '3.', '10.'] },
{ name: 'Roman with custom start and parentheses', format: 'upperRomanParenthesis', start: 8, restart: null, before: ['(VIII)', '(IX)', '(X)'], after: ['(VIII)', '(IX)', '(X)', '(XI)'] },
] as const) test(`Enter immediately renumbers ${sample.name} and Backspace restores the labels`, async ({ page }) => {
await open(page, 'First item.');
await page.evaluate(sample => window.editorTest.controllers[0].run(s => {
const first = window.editorTest.anchor;
const second = s.insertParagraph(first, 'after', 'Second item.').created[0].id;
const third = s.insertParagraph(second, 'after', 'Third item.').created[0].id;
s.applyListFormatRange(first, third, sample.format);
if (sample.start !== 1) s.setListStartOverride(first, sample.start);
if (sample.restart !== null) s.setListStartOverride(third, sample.restart);
}), sample);
const markers = page.locator('#pagination-container [data-rdv-editable] > [data-list-marker]');
await expect(markers).toHaveText([...sample.before]);
await settled(page);
await paragraphs(page).first().click();
await page.keyboard.press('End');
await paragraphs(page).first().evaluate(element => {
const root = element.getRootNode() as ShadowRoot;
const followingMarkers = Array.from(root.querySelectorAll('#pagination-container [data-rdv-editable] > [data-list-marker]')).slice(1);
root.addEventListener('beforeinput', () => {
// Observe the completed synchronous edit, before a later full render can hide stale labels.
Reflect.set(window, 'immediateListLabels', Array.from(root.querySelectorAll('#pagination-container [data-rdv-editable] > [data-list-marker]')).map(marker => marker.textContent?.trim()));
Reflect.set(window, 'retainedMarkers', followingMarkers.filter(marker => marker.isConnected && marker.querySelector('[data-docx-tab]')).length);
});
});
await page.keyboard.press('Enter');
expect(await page.evaluate(() => Reflect.get(window, 'immediateListLabels'))).toEqual(sample.after);
expect(await page.evaluate(() => Reflect.get(window, 'retainedMarkers'))).toBe(2);
await page.keyboard.press('Backspace');
expect(await page.evaluate(() => Reflect.get(window, 'immediateListLabels'))).toEqual(sample.before);
expect(await nativeText(page)).toEqual(['First item.', 'Second item.', 'Third item.']);
expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]);
});

for (const kind of ['footnote', 'endnote'] as const) {
for (const gesture of ['Backspace after', 'Delete before', 'Backspace selection', 'Delete selection']) {
test(`${gesture} removes the ${kind} reference and definition in one undo step`, async ({ page }) => {
Expand Down