From 82c3de76dfd7b34ae99a9c79870fad06debe6940 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 17 Sep 2026 01:43:23 -0500 Subject: [PATCH] Fix keyboard deletion of footnote and endnote references --- src/editing/CanvasEditor.ts | 34 ++++++---- src/editing/canvasDom.ts | 40 ++++++++++-- tests/browser/canvas.spec.ts | 119 +++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/editing/CanvasEditor.ts b/src/editing/CanvasEditor.ts index d3d023f..f38f4e7 100644 --- a/src/editing/CanvasEditor.ts +++ b/src/editing/CanvasEditor.ts @@ -2,7 +2,7 @@ import type { CharSpan, DocxSession, EditResult, FormatOp } from 'docxodus/core' import type { DocxSessionController } from '../session'; import { editableText, paragraphTextSteps, replaceParagraphText, textChange, textChangeAtSelection } from './text'; import { shadowSelection } from './selection'; -import { canvasParagraphs, canvasText, caretAtPoint, domPoint, generatedContent, normalizedText, prepareCanvasBreaks, prepareCanvasHyphens, readCanvasRange, restoreCanvasRange, samePoint } from './canvasDom'; +import { canvasParagraphs, canvasText, caretAtPoint, domPoint, generatedContent, normalizedText, prepareCanvasBreaks, prepareCanvasHyphens, readCanvasDeletion, readCanvasRange, restoreCanvasRange, samePoint } from './canvasDom'; import type { CanvasPoint, CanvasRange } from './canvasDom'; export interface CanvasEditorSnapshot { suspended: boolean; pending: boolean; composing: boolean; conflict: boolean; format: FormatOp | null } @@ -154,8 +154,8 @@ export class CanvasEditor { prepareCanvasBreaks(block); block.querySelectorAll(`${generatedContent}, [data-docx-tab], del, img`).forEach(node => node.setAttribute('contenteditable', 'false')); if (!text && !canvasText(block).trim() && !block.querySelector('img, br:not([data-rdv-empty]), [data-docx-tab]')) { - const marker = block.querySelector('[data-list-marker="true"]'); - block.replaceChildren(...(marker ? [marker] : []), Object.assign(document.createElement('br'), { ariaHidden: 'true' })); + const markers = block.querySelectorAll('[data-list-marker="true"], a.footnote-ref, a.endnote-ref'); + block.replaceChildren(...markers, Object.assign(document.createElement('br'), { ariaHidden: 'true' })); block.lastElementChild?.setAttribute('data-rdv-empty', 'true'); } } @@ -396,29 +396,37 @@ export class CanvasEditor { } private removeRange(session: DocxSession, range: CanvasRange) { const { start, end } = range; + const results: EditResult[] = []; + for (const note of range.notes ?? []) { + const anchor = Object.keys(this.controller.getAnchorIndex()).find(id => id.startsWith(`${note.kind}:`) && + new DOMParser().parseFromString(session.raw.getXml(id), 'application/xml').documentElement + .getAttributeNS('http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'id') === note.id); + if (!anchor) throw new Error('This note changed. Wait for the page to refresh before deleting it.'); + results.push(check(session.deleteBlock(anchor))); + } // A caret has no text to remove. The following native split/insertion // validates its own anchor/span without two redundant formatting reads. - if (samePoint(start, end)) return { results: [] as EditResult[], removed: [] as string[] }; - if (start.anchorId === end.anchorId) return { results: this.replace(session, start.anchorId, start.offset, end.offset, ''), removed: [] as string[] }; + if (samePoint(start, end)) return { results, removed: [] as string[] }; + if (start.anchorId === end.anchorId) return { results: [...results, ...this.replace(session, start.anchorId, start.offset, end.offset, '')], removed: [] as string[] }; const blocks = [...new Set(this.storyBlocks(start.anchorId).map(block => block.dataset.sourceAnchorId!))]; const first = blocks.indexOf(start.anchorId), last = blocks.indexOf(end.anchorId); if (first < 0 || last <= first) throw new Error('Select text within one document story, in document order.'); const firstElement = canvasParagraphs(this.root!, start.anchorId)[0], lastElement = canvasParagraphs(this.root!, end.anchorId)[0]; const cell = firstElement.closest('td, th'); if (cell !== lastElement.closest('td, th') || blocks.slice(first, last + 1).some(id => canvasParagraphs(this.root!, id)[0].closest('td, th') !== cell)) throw new Error('Edit table cells individually.'); - const results = this.replace(session, end.anchorId, 0, end.offset, ''); + results.push(...this.replace(session, end.anchorId, 0, end.offset, '')); results.push(...this.replace(session, start.anchorId, start.offset, editableText(session.getFormatting(start.anchorId)).length, '')); const removed = blocks.slice(first + 1, last + 1); for (const anchor of blocks.slice(first + 1, last)) results.push(check(session.deleteBlock(anchor))); results.push(...this.join(session, start.anchorId, end.anchorId)); return { results, removed }; } - insertText = (value: string, paragraphBreak = false) => { - const range = this.root && readCanvasRange(this.root) || this.range; + insertText = (value: string, paragraphBreak = false, target?: CanvasRange) => { + const range = target ?? (this.root && readCanvasRange(this.root) || this.range); if (!range) return false; // A collapsed Enter is exactly one native split, which already owns an // undo/version unit. Selections and pasted text still need atomic rollback. - const atomic = !(paragraphBreak && samePoint(range.start, range.end)); + const atomic = !(paragraphBreak && samePoint(range.start, range.end) && !range.notes?.length); return this.mutate(paragraphBreak ? 'split paragraph' : 'insert text', session => { const { results, removed } = this.removeRange(session, range); const lines = paragraphBreak ? ['', ''] : value.replace(/\r\n?/g, '\n').split('\n'); @@ -538,7 +546,11 @@ export class CanvasEditor { if (event.isComposing || this.state.composing) { this.beginDraft(range.start.anchorId); return; } if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') { event.preventDefault(); if (this.commit()) this.callbacks?.onHistory(event.inputType === 'historyUndo' ? 'undo' : 'redo'); return; } if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') { event.preventDefault(); this.insertText('', true); return; } - if (range.start.anchorId !== range.end.anchorId) { event.preventDefault(); this.insertText(event.data ?? ''); return; } + if (event.inputType.startsWith('delete')) { + const deletion = readCanvasDeletion(root, event); + if (deletion?.notes?.length) { event.preventDefault(); this.insertText('', false, deletion); return; } + } + if (range.start.anchorId !== range.end.anchorId || range.notes?.length) { event.preventDefault(); this.insertText(event.data ?? ''); return; } if (event.inputType.startsWith('delete') && samePoint(range.start, range.end)) { const text = this.draft ? canvasParagraphs(root, range.start.anchorId).map(canvasText).join('') : this.text(range.start.anchorId); if (!range.start.offset && /Backward$/.test(event.inputType)) { event.preventDefault(); this.merge(-1); return; } @@ -554,7 +566,7 @@ export class CanvasEditor { const cut = (event: ClipboardEvent) => { if (this.callbacks?.readOnly) return; this.capture(); - if (!this.range || samePoint(this.range.start, this.range.end)) return; + if (!this.range || (samePoint(this.range.start, this.range.end) && !this.range.notes?.length)) return; event.preventDefault(); event.clipboardData?.setData('text/plain', shadowSelection(root)?.toString() ?? ''); this.insertText(''); }; const keydown = (event: KeyboardEvent) => { diff --git a/src/editing/canvasDom.ts b/src/editing/canvasDom.ts index 1706798..96033ed 100644 --- a/src/editing/canvasDom.ts +++ b/src/editing/canvasDom.ts @@ -1,7 +1,10 @@ import { paragraphSelector, shadowSelection } from './selection'; export interface CanvasPoint { anchorId: string; offset: number } -export interface CanvasRange { start: CanvasPoint; end: CanvasPoint; backward: boolean } +export interface CanvasRange { + start: CanvasPoint; end: CanvasPoint; backward: boolean; + notes?: { kind: 'fn' | 'en'; id: string }[]; +} export const generatedContent = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"], a.comment-marker'; const ignored = `${generatedContent}, [data-rdv-empty], [data-rdv-presentation], [data-docx-tab], br, del, [data-revision-type="deleted"]`; @@ -80,18 +83,47 @@ export function canvasPoint(root: HTMLElement, node: Node, offset: number): Canv return { anchorId: block.dataset.sourceAnchorId!, offset: fragments.slice(0, index).reduce((length, part) => length + canvasText(part).length, 0) + canvasText(prefix.cloneContents()).length }; } -export function readCanvasRange(root: HTMLElement): CanvasRange | null { +export function readCanvasRange(root: HTMLElement, target?: StaticRange): CanvasRange | null { const selection = shadowSelection(root); if (!selection?.rangeCount) return null; const tree = root.getRootNode() as ShadowRoot; const composed = (selection as Selection & { getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[] }).getComposedRanges?.({ shadowRoots: [tree] }); - const range = composed?.[0] ?? selection.getRangeAt(0); + const range = target ?? composed?.[0] ?? selection.getRangeAt(0); const start = canvasPoint(root, range.startContainer, range.startOffset); if (start && range.startContainer === range.endContainer && range.startOffset === range.endOffset) return { start, end: start, backward: false }; const end = canvasPoint(root, range.endContainer, range.endOffset); if (!start || !end) return null; + // Notes occupy no native text offsets, but remain selectable DOM content. + const selected = root.ownerDocument.createRange(); + selected.setStart(range.startContainer, range.startOffset); selected.setEnd(range.endContainer, range.endOffset); + const notes = Array.from(root.querySelectorAll('#pagination-container a.footnote-ref, #pagination-container a.endnote-ref')) + .filter(ref => selected.intersectsNode(ref)).map(ref => ({ + kind: ref.matches('.footnote-ref') ? 'fn' as const : 'en' as const, + id: ref.dataset.footnoteId ?? ref.dataset.endnoteId ?? ref.id.replace(/^(fn|en)-ref-/, ''), + })); const anchor = selection.anchorNode && canvasPoint(root, selection.anchorNode, selection.anchorOffset); - return { start, end, backward: !!anchor && !samePoint(start, end) && samePoint(anchor, end) }; + return { start, end, notes, backward: !!anchor && !samePoint(start, end) && samePoint(anchor, end) }; +} + +/** Older beforeinput implementations omit target ranges for collapsed deletion. */ +export function readCanvasDeletion(root: HTMLElement, event: InputEvent): CanvasRange | null { + const target = event.getTargetRanges?.()[0]; + if (target) return readCanvasRange(root, target); + const selection = shadowSelection(root); + const caret = selection?.rangeCount ? selection.getRangeAt(0) : null; + if (caret?.collapsed && /^(deleteContentBackward|deleteContentForward)$/.test(event.inputType)) { + const backward = event.inputType.endsWith('Backward'); + const block = (caret.startContainer instanceof Element ? caret.startContainer : caret.startContainer.parentElement)?.closest(paragraphSelector); + for (const ref of block?.querySelectorAll('a.footnote-ref, a.endnote-ref') ?? []) { + const note = root.ownerDocument.createRange(); note.selectNode(ref); + const order = note.compareBoundaryPoints(backward ? Range.END_TO_END : Range.START_TO_START, caret); + if (backward ? order > 0 : order < 0) continue; + const gap = caret.cloneRange(); + if (backward) gap.setStartAfter(ref); else gap.setEndBefore(ref); + if (!gap.toString() && !gap.cloneContents().querySelector('br, img, [data-docx-tab]')) return readCanvasRange(root, note); + } + } + return readCanvasRange(root); } function pointInNode(node: Node, offset: number): [Node, number] | null { diff --git a/tests/browser/canvas.spec.ts b/tests/browser/canvas.spec.ts index 4ef367b..c943db9 100644 --- a/tests/browser/canvas.spec.ts +++ b/tests/browser/canvas.spec.ts @@ -377,3 +377,122 @@ 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 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 }) => { + await open(page, 'Before after.'); + await page.evaluate(kind => window.editorTest.controllers[0].run(s => + kind === 'footnote' ? s.insertFootnote(window.editorTest.anchor, 6, 'Note to remove.') + : s.insertEndnote(window.editorTest.anchor, 6, 'Note to remove.')), kind); + const reference = page.locator(`#pagination-container a.${kind}-ref`); + await expect(reference).toHaveCount(1); + await settled(page); + await reference.evaluate((ref, gesture) => { + ref.closest('[contenteditable="true"]')!.focus(); + const range = document.createRange(); + range.selectNode(ref); + if (!gesture.endsWith('selection')) range.collapse(gesture.endsWith('before')); + const selection = (ref.getRootNode() as ShadowRoot & { getSelection(): Selection }).getSelection(); + selection.removeAllRanges(); selection.addRange(range); + }, gesture); + await page.keyboard.press(gesture.split(' ')[0]); + const notes = () => page.evaluate(() => window.editorTest.controllers[0].read(s => + Object.values(s.project().anchorIndex).filter(a => a.kind === 'fn' || a.kind === 'en').length)); + await expect.poll(notes).toBe(0); + await expect(reference).toHaveCount(0); + expect(await nativeText(page)).toEqual(['Before after.']); + await page.keyboard.press('Control+z'); + await expect.poll(notes).toBe(1); + await expect(reference).toHaveCount(1); + await page.keyboard.press('Control+y'); + await expect.poll(notes).toBe(0); + await page.keyboard.press('Control+s'); + const saved = await page.evaluate(async () => { + const controller = new window.rdv.DocxSessionController(); + const session = await controller.open(window.editorTest.saved!, {}, '/wasm/'); + const result = { text: session.getFormatting(window.editorTest.anchor)!.runs.map(r => r.text).join(''), + notes: Object.values(session.project().anchorIndex).filter(a => a.kind === 'fn' || a.kind === 'en').length, + valid: session.getPackageManifest().isValid }; + controller.close(); return result; + }); + expect(saved).toEqual({ text: 'Before after.', notes: 0, valid: true }); + expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]); + }); + } +} + +for (const targets of ['missing', 'empty'] as const) test(`Backspace removes a note when browser target ranges are ${targets}`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await open(page, 'Keep text.'); + await page.evaluate(() => window.editorTest.controllers[0].run(s => s.insertFootnote(window.editorTest.anchor, 4, 'Delete note.'))); + const reference = page.locator('#pagination-container a.footnote-ref'); + await expect(reference).toHaveCount(1); + await settled(page); + await reference.evaluate((ref, targets) => { + Object.defineProperty(InputEvent.prototype, 'getTargetRanges', { configurable: true, value: targets === 'missing' ? undefined : () => [] }); + ref.closest('[contenteditable="true"]')!.focus(); + const range = document.createRange(); range.setStartAfter(ref); range.collapse(true); + const selection = (ref.getRootNode() as ShadowRoot & { getSelection(): Selection }).getSelection(); + selection.removeAllRanges(); selection.addRange(range); + }, targets); + await page.keyboard.press('Backspace'); + await expect(reference).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.editorTest.controllers[0].read(s => + Object.values(s.project().anchorIndex).filter(a => a.kind === 'fn').length))).toBe(0); + expect(await nativeText(page)).toEqual(['Keep text.']); + expect(errors).toEqual([]); + expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]); +}); + +test('deleting text with both note kinds keeps a reference at the selection boundary', async ({ page }) => { + await open(page, 'Left middle right.'); + const original = await page.evaluate(() => window.editorTest.controllers[0].run(s => { + const anchor = window.editorTest.anchor; + s.insertFootnote(anchor, 4, 'Remove footnote.'); + s.insertEndnote(anchor, 4, 'Remove endnote.'); + s.insertFootnote(anchor, 11, 'Keep footnote.'); + return s.getFormatting(anchor)!.runs; + })); + const references = page.locator('#pagination-container a.footnote-ref, #pagination-container a.endnote-ref'); + await expect(references).toHaveCount(3); + await settled(page); + await references.first().evaluate(ref => { + const paragraph = ref.closest('[contenteditable="true"]')!; + paragraph.focus(); + const range = document.createRange(); + range.setStart(paragraph.querySelector('span')!.firstChild!, 2); + range.setEndBefore(paragraph.querySelectorAll('a.footnote-ref')[1]); + const selection = (ref.getRootNode() as ShadowRoot & { getSelection(): Selection }).getSelection(); + selection.removeAllRanges(); selection.addRange(range); + }); + await page.keyboard.press('Delete'); + await expect.poll(() => nativeText(page)).toEqual(['Le right.']); + await expect(references).toHaveCount(1); + await expect(paragraphs(page).filter({ hasText: 'Keep footnote.' })).toHaveCount(1); + await expect(paragraphs(page).filter({ hasText: 'Remove ' })).toHaveCount(0); + await page.keyboard.press('Control+z'); + await expect.poll(() => page.evaluate(() => window.editorTest.controllers[0].read(s => s.getFormatting(window.editorTest.anchor)!.runs))).toEqual(original); + await expect(references).toHaveCount(3); + expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]); +}); + +test('a note-only paragraph retains its reference and accepts typing after deletion', async ({ page }) => { + await open(page, ''); + await page.evaluate(() => window.editorTest.controllers[0].run(s => s.insertFootnote(window.editorTest.anchor, 0, 'Only note.'))); + const reference = page.locator('#pagination-container a.footnote-ref'); + await expect(reference).toHaveCount(1); + await settled(page); + await reference.evaluate(ref => { + ref.closest('[contenteditable="true"]')!.focus(); + const range = document.createRange(); range.setStartAfter(ref); range.collapse(true); + const selection = (ref.getRootNode() as ShadowRoot & { getSelection(): Selection }).getSelection(); + selection.removeAllRanges(); selection.addRange(range); + }); + await page.keyboard.press('Backspace'); + await expect(reference).toHaveCount(0); + await page.keyboard.type('Still editable.'); + await expect.poll(() => nativeText(page)).toEqual(['Still editable.']); + expect(await page.evaluate(() => window.editorTest.errors)).toEqual([]); +});