refactor: move selection surgery into rich-text-dom.ts (RD-21)
deleteAdjacentChip and insert did getSelection()/Range work inside the component, which pushed it over the max-lines budget under a disable comment. rich-text-dom.ts already owns the DOM boundary, so the surgery moves there as two new exports, chipAtCaret and insertChipAtCaret, and the component keeps only its event-handling and output concerns. adjacentChip stays exported with its own spec case. The component disable comment is gone, since the file is now under the line budget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. 538 frontend behaviours across
|
||||
**is** the suite, reshaped for a business reader. 543 frontend behaviours across
|
||||
9 contexts; 261 backend behaviours across 42 test
|
||||
classes.
|
||||
|
||||
@@ -758,6 +758,14 @@ classes.
|
||||
- redirects an anonymous user to /login without waiting for caps
|
||||
- reads authentication through the port, not an app-local store
|
||||
|
||||
#### chipAtCaret / insertChipAtCaret (selection surgery)
|
||||
|
||||
- chipAtCaret returns the chip before a Backspace caret
|
||||
- chipAtCaret returns null for a non-collapsed selection
|
||||
- chipAtCaret returns null for a caret outside root
|
||||
- insertChipAtCaret splices at the caret and leaves the caret after the chip
|
||||
- insertChipAtCaret appends when there is no selection inside root
|
||||
|
||||
#### createDebouncedSave
|
||||
|
||||
- flushes after the delay when canSave is true
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { adjacentChip, readBlock, renderInto } from './rich-text-dom';
|
||||
import {
|
||||
adjacentChip,
|
||||
chipAtCaret,
|
||||
insertChipAtCaret,
|
||||
readBlock,
|
||||
renderInto,
|
||||
} from './rich-text-dom';
|
||||
|
||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||
|
||||
@@ -131,3 +137,97 @@ describe('rich-text DOM boundary', () => {
|
||||
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chipAtCaret / insertChipAtCaret (selection surgery)', () => {
|
||||
// jsdom's Selection only accepts ranges over nodes attached to the live document,
|
||||
// so every case here mounts its `root` under document.body and unmounts it after.
|
||||
let mounted: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const el of mounted) el.remove();
|
||||
mounted = [];
|
||||
document.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
function mount(root: HTMLElement): HTMLElement {
|
||||
document.body.appendChild(root);
|
||||
mounted.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function setCaret(node: Node, offset: number): void {
|
||||
const sel = document.getSelection();
|
||||
if (!sel) throw new Error('no selection in this environment');
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function makeChip(): HTMLElement {
|
||||
const chip = document.createElement('span');
|
||||
chip.dataset['phKey'] = 'naam';
|
||||
return chip;
|
||||
}
|
||||
|
||||
it('chipAtCaret returns the chip before a Backspace caret', () => {
|
||||
const root = mount(document.createElement('div'));
|
||||
const chip = makeChip();
|
||||
const after = document.createTextNode('bb');
|
||||
root.append(chip, after);
|
||||
setCaret(after, 0);
|
||||
expect(chipAtCaret(root, -1)).toBe(chip);
|
||||
});
|
||||
|
||||
it('chipAtCaret returns null for a non-collapsed selection', () => {
|
||||
const root = mount(document.createElement('div'));
|
||||
const chip = makeChip();
|
||||
const text = document.createTextNode('bb');
|
||||
root.append(chip, text);
|
||||
const sel = document.getSelection();
|
||||
if (!sel) throw new Error('no selection in this environment');
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 1);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
expect(chipAtCaret(root, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('chipAtCaret returns null for a caret outside root', () => {
|
||||
const root = mount(document.createElement('div'));
|
||||
const other = mount(document.createElement('div'));
|
||||
const text = document.createTextNode('bb');
|
||||
other.appendChild(text);
|
||||
setCaret(text, 0);
|
||||
expect(chipAtCaret(root, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('insertChipAtCaret splices at the caret and leaves the caret after the chip', () => {
|
||||
const root = mount(document.createElement('div'));
|
||||
const text = document.createTextNode('aabb');
|
||||
root.appendChild(text);
|
||||
setCaret(text, 2); // caret between "aa" and "bb"
|
||||
const chip = makeChip();
|
||||
insertChipAtCaret(root, chip);
|
||||
expect(root.textContent).toBe('aabb');
|
||||
expect(Array.from(root.childNodes)).toContain(chip);
|
||||
const sel = document.getSelection();
|
||||
if (!sel) throw new Error('no selection in this environment');
|
||||
const range = sel.getRangeAt(0);
|
||||
expect(range.collapsed).toBe(true);
|
||||
expect(root.childNodes[range.startOffset - 1]).toBe(chip);
|
||||
});
|
||||
|
||||
it('insertChipAtCaret appends when there is no selection inside root', () => {
|
||||
const root = mount(document.createElement('div'));
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'line';
|
||||
root.appendChild(p);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
const chip = makeChip();
|
||||
insertChipAtCaret(root, chip);
|
||||
expect(p.lastChild).toBe(chip);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,6 +186,38 @@ export function adjacentChip(
|
||||
return isChip(sibling) ? sibling : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a collapsed caret sits next to, or null. `direction` is -1 for
|
||||
* Backspace and 1 for Delete. Returns null when the selection is absent, is a
|
||||
* range rather than a caret, or sits outside `root`.
|
||||
*/
|
||||
export function chipAtCaret(root: HTMLElement, direction: -1 | 1): HTMLElement | null {
|
||||
const sel = root.ownerDocument.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!root.contains(range.startContainer)) return null;
|
||||
return adjacentChip(range.startContainer, range.startOffset, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `chip` at the caret when the selection is inside `root`, and leave the
|
||||
* caret after it. With no usable selection, append to the last line instead.
|
||||
*/
|
||||
export function insertChipAtCaret(root: HTMLElement, chip: HTMLElement): void {
|
||||
const sel = root.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && root.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
(root.lastElementChild ?? root).appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function markOf(el: HTMLElement): Mark | null {
|
||||
switch (el.tagName) {
|
||||
case 'STRONG':
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable max-lines */ // toolbar + contenteditable logic in one component — removed by RD-21
|
||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
import { chipAtCaret, createChip, insertChipAtCaret, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||
@@ -257,15 +256,7 @@ export class RichTextEditorComponent {
|
||||
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return;
|
||||
const chip = adjacentChip(
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
e.key === 'Backspace' ? -1 : 1,
|
||||
);
|
||||
const chip = chipAtCaret(el, e.key === 'Backspace' ? -1 : 1);
|
||||
if (!chip) return;
|
||||
e.preventDefault();
|
||||
chip.remove();
|
||||
@@ -276,19 +267,7 @@ export class RichTextEditorComponent {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!key || !el) return;
|
||||
el.focus();
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
(el.lastElementChild ?? el).appendChild(chip);
|
||||
}
|
||||
insertChipAtCaret(el, createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key)));
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user