Files
atomic-design-poc/docs/project/readable-codebase/RD-21-rich-text-dom.md
T
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

7.5 KiB

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:

    /** 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:

    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.

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:

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):

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