# 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.