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:
eho
2026-09-04 22:42:21 +02:00
co-authored by Claude Sonnet 5
parent 8a3e42015b
commit 4b3e6a6cfd
6 changed files with 313 additions and 28 deletions
@@ -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();
}
}