Files
ehoandClaude Sonnet 5 4b3e6a6cfd 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>
2026-09-04 22:42:21 +02:00

167 lines
7.5 KiB
Markdown

# RD-21 — Move the selection surgery into `rich-text-dom.ts`, and delete the disable
Status: done
Source: PLAN.md 3h, order step 3
## Why
`rich-text-editor.component.ts` measures ~256 effective lines against a limit of 250, so it
carries `/* eslint-disable max-lines */`. The lines that put it over are not component
concerns: `deleteAdjacentChip` and `insert` do `getSelection()`/`Range` surgery inside a
component whose job is the toolbar and the `contenteditable` host.
`rich-text-dom.ts` already exists beside it, already owns the DOM boundary, and already has a
spec. The seam is built. This is the cheapest of Phase 3's seven splits, and it converts two
untested imperative branches into spec cases.
## Read first
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — the four exports today
(`renderInto`, `createChip`, `readBlock`, `adjacentChip`) and the file's header comment,
which already states the contract this ticket extends.
- `rich-text-editor.component.ts:257-293` — `deleteAdjacentChip` and `insert`, the two bodies
that move.
- `rich-text-dom.spec.ts` — 8 cases, plain jsdom, no TestBed. The new cases join it.
## Decisions (pre-made, don't relitigate)
1. **Two new exports in `rich-text-dom.ts`, both taking the editor root:**
```ts
/** 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;
/** 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;
```
2. **`chipAtCaret` wraps `adjacentChip`; it does not replace it.** `adjacentChip` stays
exported and keeps its own spec case, which tests the node/offset arithmetic directly.
`chipAtCaret` adds the selection guards around it. The component stops importing
`adjacentChip` and imports `chipAtCaret` instead.
3. **The two component methods reduce to their component concerns:**
```ts
private deleteAdjacentChip(e: KeyboardEvent) {
const el = this.editorEl()?.nativeElement;
if (!el) return;
const chip = chipAtCaret(el, e.key === 'Backspace' ? -1 : 1);
if (!chip) return;
e.preventDefault();
chip.remove();
this.emit();
}
protected insert(key: string) {
const el = this.editorEl()?.nativeElement;
if (!key || !el) return;
el.focus();
insertChipAtCaret(el, createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key)));
this.emit();
}
```
`e.preventDefault()`, `chip.remove()`, `el.focus()` and `this.emit()` stay in the component:
they are event handling and output, not DOM boundary work.
4. **Delete `/* eslint-disable max-lines */` from line 1 of the component.** This is not
optional bookkeeping — `reportUnusedDisableDirectives` is `error`, so leaving a directive
that is no longer needed **fails the build**. The two checks pin each other: if the file is
still over budget, lint fails on `max-lines`; if it is under and the directive stays, lint
fails on the unused directive.
5. **No new file, no new folder.** `rich-text-dom.ts` is the right home and already carries the
header comment that describes exactly this responsibility.
6. **No story changes.** `rich-text-editor.stories.ts` exercises the component through the same
public surface; nothing it renders changes.
## Files
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — two new exports
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts` — new cases
- `libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts` — two shrunken methods,
changed import, and the disable deleted
- `libs/shared/docs/behaviour-spec.mdx` (regenerated, never hand-edited)
## Steps
1. Add `chipAtCaret` and `insertChipAtCaret` per decision 1, moving the bodies out of the
component rather than rewriting them.
2. Add spec cases. Cover, at minimum: `chipAtCaret` returns the chip before a Backspace caret;
returns null for a non-collapsed selection; 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`.
3. Rewrite the two component methods per decision 3 and fix the import line.
4. Delete the disable (decision 4).
5. Run `npm run gen:behaviour-spec`.
6. `git add -A`, then run the acceptance commands.
7. Update this ticket's `Status:` to `done` and the README's RD-21 row to `done`.
8. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover.
```bash
git grep -c "^export function" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 4 -> MUST be 6
git grep -c "eslint-disable max-lines" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 1 -> MUST be 0
```
The selection surgery has left the component entirely:
```bash
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 0 -> MUST be 2
git grep -c "adjacentChip" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
```
`adjacentChip` survives with its spec case (decision 2):
```bash
git grep -c "export function adjacentChip" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # MUST be 1
git grep -c " it(" libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts # is 8 -> MUST be >= 13
```
```bash
npm run ci --full # exits 0
```
## Verification
**Do not add a line-count command.** `npm run lint`, inside the gate, is the exact check and a
hand-rolled `grep -v | wc -l` is not: it cannot reproduce eslint's `skipComments` for a trailing
comment or for the component's inline template. Decision 4 explains why lint alone pins both
directions.
**`--full` is required.** This edits `libs/shared/src/ui/**`, which the README's rule names
explicitly.
jsdom supports `document.getSelection()`, `Range.deleteContents()`, `insertNode`,
`removeAllRanges` and `addRange`, so every new case runs in the existing plain-vitest setup. No
TestBed, no browser.
## Out of scope
- Splitting the component further. It is over by a handful of lines, not structurally wrong.
- Touching `renderInto`, `readBlock` or `createChip`.
- The `ponytail:` note in `rich-text-dom.ts`'s header about exotic pasted markup. That is a
recorded limitation, not this ticket's work.
## Risks
- **Deleting the disable is mandatory, not cosmetic** (decision 4). Forgetting it fails lint
with `Unused eslint-disable directive`, which reads like an unrelated error.
- **Keep `adjacentChip` exported.** Its spec case imports it directly; folding it into
`chipAtCaret` deletes a test that covers node/offset arithmetic the wrapper does not.
- **Move the bodies, do not rewrite them.** The caret placement after insert
(`setStartAfter` → `collapse(true)` → `removeAllRanges` → `addRange`) is the part users feel;
a "cleaner" rewrite is where a regression hides, and no story catches it.
- **`behaviour-spec.mdx` drift** from the new spec titles. Regenerate in the same commit. A name
used in a `describe` or `it` title also lands in that generated file — count it if you add a
grep for one.