|
| 1 | +# Backspace/Delete — One Decision Model |
| 2 | + |
| 3 | +Rules for `src/core/event/rules/keydown.rule.{backspace,delete}.js`. The two are mirrors (`front`/`end`). |
| 4 | +Nearly every bug found here came from one habit: a branch deciding "where am I?" from **local siblings**, |
| 5 | +so a list or quote boundary looked like the end of the document. |
| 6 | + |
| 7 | +## The model — ask these, in this order |
| 8 | + |
| 9 | +1. **Is the caret at the line's edge?** → `format.isEdgeLine(...)`, or `isEdgeBreakCaret(...)` when the |
| 10 | + caret sits on an empty line's `<br>`. |
| 11 | +2. **What is the neighbouring line?** → `getAdjacentLine(format, formatEl, edge)` — the previous/next line |
| 12 | + **in document order**, stepping out of and into blocks *and out of nested list cells*, stopping at |
| 13 | + closure blocks (table cell) and the root. It returns `null` both at the document edge and when the |
| 14 | + neighbour is not a line (a component) — when the two must be told apart, use `getAdjacentElement` |
| 15 | + (same walk, unfiltered). |
| 16 | +3. Then exactly one of: |
| 17 | + - **No neighbour at all** (`getAdjacentElement` is `null`) → document edge. `preventStop` so the |
| 18 | + browser can't reach outside, do nothing else. A component next door is **not** the document edge — |
| 19 | + fall through so the component-select branches can claim it; preventing there kills the key AND the |
| 20 | + component selection. |
| 21 | + - **This line is empty** → drop it, caret to the neighbour (`getEmptyLineMergeTarget` + the |
| 22 | + `emptyLine.merge*` effects). Same path for a plain line and a list cell. |
| 23 | + - **Neighbour is across a block boundary** (`neighbor.parentElement !== formatEl.parentElement`) → |
| 24 | + merge it ourselves (`mergeLineInto`). The browser merges these badly. |
| 25 | + - **Plain sibling lines** → return `true` without preventing. Native merge is correct here; don't |
| 26 | + reimplement it. |
| 27 | + |
| 28 | +List cells are ordinary lines in this model (`isNormalLine('LI')` is `true`). Two deliberate exceptions, |
| 29 | +both because a list owns a *different* gesture there: |
| 30 | +- Backspace at the **head of a cell** outdents — the list branch owns it, so the merge branch skips cells. |
| 31 | +- A cell holding a nested list belongs to `getNestedListTarget`, which lifts the list instead. |
| 32 | + |
| 33 | +## Never navigate by local siblings |
| 34 | + |
| 35 | +`formatEl.nextSibling` / `previousElementSibling` answer "within my parent", not "in the document". Using |
| 36 | +them for edge decisions is what made Delete dead at the last `<li>` (last in the `<ul>` read as end of |
| 37 | +document) and at a line before a list. **Use `getAdjacentLine`.** Raw sibling access is fine only for |
| 38 | +inspecting the line's own children. |
| 39 | + |
| 40 | +## The caret's neighbours may be invisible |
| 41 | + |
| 42 | +`<br>` and zero-width text are filler, not content — a raw sibling test sees them and reports "not at the |
| 43 | +edge", so the rule stands down and the browser just eats the filler: one keypress, nothing visible. |
| 44 | +Producers clean up (`_normalizeEditRange` drops the zero-width once the caret moves onto the `<br>`; |
| 45 | +`backspace.list.mergePrev` calls `stripTrailingBreaks`), and helpers skip filler when walking siblings. |
| 46 | + |
| 47 | +## Fail open — a prevented key must do something |
| 48 | + |
| 49 | +`contentEditable` hands us shapes we did not predict; the failure that hurts is the key doing **nothing**. |
| 50 | + |
| 51 | +- Never `preventDefault` on a path that then makes no DOM change, no caret move, no component selection. |
| 52 | + The only deliberate no-ops: document start (Backspace), document end (Delete), closure-block boundaries. |
| 53 | +- **Never report a key as handled when the branch had nothing to do** — that stops every rule behind it. |
| 54 | + Gate entry on the target existing (`getNestedListTarget(...)` before the nested-list branch) — but gate |
| 55 | + only the path that needs it: a collapsed caret needs a nested list to lift, while a real selection is |
| 56 | + handled (`html.remove`) regardless, so the gate must not cut that path off. |
| 57 | +- No branch applies → return `true` without preventing, and let the browser do it. |
| 58 | + |
| 59 | +## Verify before claiming it works |
| 60 | + |
| 61 | +jsdom cannot perform native contentEditable edits, so `changed === false` only means *the editor* did not |
| 62 | +act. Judge by emitted actions, and sweep: |
| 63 | + |
| 64 | +- Dump the reduced action list (`reduceDeleteDown(actions, ports, ctx)`) — that is the real decision. |
| 65 | +- Sweep a document set × {Backspace, Delete} × {front, end} over every line, flagging |
| 66 | + `prevented && DOM unchanged && caret unchanged`, any throw, and any lost table cell. |
| 67 | +- Always check all four directions of a structure pair (p→list, list→p, and both Backspace/Delete) and the |
| 68 | + **paragraph equivalent**. A divergence between them is the bug. |
0 commit comments