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
34 changes: 23 additions & 11 deletions src/editing/CanvasEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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');
}
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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; }
Expand All @@ -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) => {
Expand Down
40 changes: 36 additions & 4 deletions src/editing/canvasDom.ts
Original file line number Diff line number Diff line change
@@ -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"]`;

Expand Down Expand Up @@ -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<HTMLElement>('#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 {
Expand Down
119 changes: 119 additions & 0 deletions tests/browser/canvas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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([]);
});