From dcf04f837ff2a914d0a17c06e0b9553b431a5088 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 17 Sep 2026 02:00:39 -0500 Subject: [PATCH] Refresh numbered list labels immediately after structural edits --- src/editing/CanvasEditor.ts | 19 ++++++++++ src/nativeSession.ts | 11 ++++++ src/session.test.ts | 2 +- src/session.ts | 6 ++++ tests/browser/canvas.spec.ts | 69 ++++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/editing/CanvasEditor.ts b/src/editing/CanvasEditor.ts index 10f3562..bb3b22b 100644 --- a/src/editing/CanvasEditor.ts +++ b/src/editing/CanvasEditor.ts @@ -352,6 +352,24 @@ export class CanvasEditor { } } + private refreshListMarkers() { + if (!this.root) return; + const grouped = new Map(); + 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; @@ -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); } diff --git a/src/nativeSession.ts b/src/nativeSession.ts index 9b55025..57d1d27 100644 --- a/src/nativeSession.ts +++ b/src/nativeSession.ts @@ -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 => { + // 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(); } + }, }; } diff --git a/src/session.test.ts b/src/session.test.ts index bd113c1..0e20116 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -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()); diff --git a/src/session.ts b/src/session.ts index 8196d4a..0f18e44 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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(selector: (session: DocxSession) => T): T { if (!this.native) throw new Error('Open a document session first.'); diff --git a/tests/browser/canvas.spec.ts b/tests/browser/canvas.spec.ts index 5d47e9b..90bddc6 100644 --- a/tests/browser/canvas.spec.ts +++ b/tests/browser/canvas.spec.ts @@ -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(); @@ -457,3 +490,39 @@ test('select-all formats body paragraphs without changing their interleaved foot expect(formatted.filter(p => p.scope === 'fn').every(p => p.runs.every(r => !r.effective.bold))).toBe(true); 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([]); +});