@helping-ai-workflow/md2doc 2.10.1 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,46 @@
10
10
  return lines.slice(block.startLine - 1, block.endLine).join('\n');
11
11
  }
12
12
 
13
+ // ── T8 item 1: the range helpers refuse an INVERTED range THEMSELVES ────
14
+ // A block that owns no source line has endLine === startLine - 1 (see
15
+ // blockOwnsNoLine() further down) and blocks do not tile the document, so an
16
+ // inverted range is a shape callers can genuinely arrive at. Both helpers
17
+ // below were written assuming endLine >= startLine, and handed an inverted
18
+ // range neither fails — each does something plausible and wrong:
19
+ // commitRangeEdit() `lines.slice(startLine-1, endLine)` is [], so the
20
+ // unchanged-text test compares against '', and
21
+ // ops.replaceLines() splices without removing — an
22
+ // INSERT where a replace was asked for. Measured:
23
+ // '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' ->
24
+ // '# Doc\n\n- a\n\nZZZ\n- - b\n'.
25
+ // commitRangeRemoval() the blank-line absorption samples `lines[el]` (the
26
+ // range's own first line) and `lines[sl-2]` (a line
27
+ // owned by whatever precedes), finds one blank and
28
+ // deletes it. Measured: '# Doc\n\n- a\n\n- - b\n'
29
+ // -> '# Doc\n\n- a\n- - b\n', silently.
30
+ // That single root cause was fixed FIVE separate times at five call sites
31
+ // (T3 arming, T3 same-line child commit, T4 gutter delete, T4 raw edit,
32
+ // T7 insertBlockBelow). Those guards stay — they can show the user a banner,
33
+ // which this cannot — but the rule now also lives in the one place every
34
+ // path must pass through.
35
+ //
36
+ // The refusal REUSES the existing "nothing changed" return shape (`op: null`,
37
+ // the caller's own arrays handed straight back) plus a `refused` tag, rather
38
+ // than throwing: every call site already has a correct abort path for
39
+ // op === null, and none of the five is inside a try/catch, so a throw would
40
+ // convert a silent wrong edit into an unhandled rejection mid-gesture — a
41
+ // different failure, not a safer one. The tag is what makes the refusal
42
+ // observable; `console.error` is what makes it findable in a real session.
43
+ function refuseInvertedRange(state, who, startLine, endLine) {
44
+ if (endLine >= startLine) return null;
45
+ if (typeof console !== 'undefined' && console && typeof console.error === 'function') {
46
+ console.error('[md2doc] ' + who + ' refused an inverted line range: startLine=' +
47
+ startLine + ' endLine=' + endLine + ' (a block that owns no source line, or a ' +
48
+ 'caller that computed a range backwards) — nothing was committed');
49
+ }
50
+ return { lines: state.lines, blocks: state.blocks, op: null, refused: 'inverted-range' };
51
+ }
52
+
13
53
  // Pure: apply a raw-edit commit to an EXPLICIT line range (startLine..endLine,
14
54
  // 1-indexed inclusive); push onto stack.
15
55
  // Returns {lines, blocks, op}; op === null when text is unchanged.
@@ -17,6 +57,8 @@
17
57
  // so that only blocks after the committed range are shifted — not blocks
18
58
  // inside it. Guards the no-anchor case (range before every block).
19
59
  function commitRangeEdit(state, startLine, endLine, newText) {
60
+ const refusal = refuseInvertedRange(state, 'commitRangeEdit', startLine, endLine);
61
+ if (refusal) return refusal;
20
62
  const before = state.lines.slice(startLine - 1, endLine);
21
63
  const after = newText.split('\n');
22
64
  if (before.join('\n') === after.join('\n')) {
@@ -48,6 +90,8 @@
48
90
  // widening endLine to cover an adjacent blank never reaches across into the
49
91
  // next real block and mis-shifts it.
50
92
  function commitRangeRemoval(state, startLine, endLine) {
93
+ const refusal = refuseInvertedRange(state, 'commitRangeRemoval', startLine, endLine);
94
+ if (refusal) return refusal;
51
95
  const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
52
96
  let sl = startLine, el = endLine;
53
97
  // state.lines[el] (0-indexed) is the line immediately AFTER the range.
@@ -103,6 +147,14 @@
103
147
  // pure insertion/pure removal without a third op shape.
104
148
  function commitBlockInsertion(state, blockId, newBlockLines) {
105
149
  const block = state.blocks.find((b) => b.id === blockId);
150
+ // T8 item 1, third helper in the same family: the anchor's range is read as
151
+ // an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
152
+ // the trailing-blank probe), so an inverted one puts the new block ABOVE the
153
+ // block it was anchored to, inside the previous one's territory.
154
+ // refuseInvertedRange() takes the range, so the anchor's own is passed.
155
+ const anchorRefusal = refuseInvertedRange(state, 'commitBlockInsertion',
156
+ block.startLine, block.endLine);
157
+ if (anchorRefusal) return anchorRefusal;
106
158
  const endLine = block.endLine;
107
159
  const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
108
160
  const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '';
@@ -122,6 +174,35 @@
122
174
  return { lines: r.lines, blocks, op, newStartLine };
123
175
  }
124
176
 
177
+ // ── T8 review MEDIUM-1: the one correct way to undo a failed render ─────
178
+ // Six sites in this file share the shape "commit optimistically, re-render,
179
+ // and if the render failed put `lines` back". The inlined version of that
180
+ // last step was `const rollback = stack.undo(lines); lines = rollback ?
181
+ // rollback.lines : prevLines;` — correct only while EVERY commit pushed an
182
+ // op. Since commitRangeEdit()/commitRangeRemoval()/commitBlockInsertion()
183
+ // learned to REFUSE an inverted range (above), a commit can return
184
+ // `op: null` having pushed nothing, and UndoStack.undo() pops `_done`
185
+ // unconditionally (lib/editor/lineops.js) — so at the two sites that never
186
+ // inspected `result.op` (insertBlockBelow / deleteBlockViaGutter) a refusal
187
+ // followed by a render failure popped and reversed the user's PREVIOUS,
188
+ // UNRELATED edit. Latent only because those two check blockOwnsNoLine()
189
+ // first; S2 gives li blocks a + and it goes live.
190
+ //
191
+ // One helper rather than two `if (result.op === null) return;` lines,
192
+ // because the idiom is copy-pasted and the seventh site will not remember
193
+ // either. Declared in the pure core so it is reachable from node: the branch
194
+ // cannot be driven through a gesture today, and a guard that can only be
195
+ // checked by grepping for its own source text is the shape that already
196
+ // failed review once on this plan.
197
+ //
198
+ // `state` needs `.lines` (the CURRENT, optimistically-assigned array) and
199
+ // `.stack`. Returns the array `lines` should become.
200
+ function rollbackFailedRender(state, result, prevLines) {
201
+ if (!result || result.op === null) return prevLines;
202
+ const rollback = state.stack.undo(state.lines);
203
+ return rollback ? rollback.lines : prevLines;
204
+ }
205
+
125
206
  function headingDepthOf(line) {
126
207
  const m = line.match(/^(#{1,6})\s?/);
127
208
  return m ? m[1].length : 1;
@@ -141,7 +222,7 @@
141
222
  }
142
223
 
143
224
  if (typeof module === 'object' && module.exports) {
144
- module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, headingDepthOf, withHeadingDepth };
225
+ module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, rollbackFailedRender, headingDepthOf, withHeadingDepth };
145
226
  return; // node: pure core only
146
227
  }
147
228
 
@@ -150,6 +231,8 @@
150
231
  const inlineMd = window.md2docInlineMd;
151
232
  const tableMd = window.md2docTableMd;
152
233
  const listMd = window.md2docListMd;
234
+ const indentClamp = window.md2docIndentClamp;
235
+ const convertMd = window.md2docConvertMd;
153
236
  const historyLib = window.md2docHistory;
154
237
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
155
238
  // 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
@@ -257,7 +340,7 @@
257
340
  // Task 8: the SAME Chromium behaviour, one substrate over — see
258
341
  // `suppressTableFocusout` just above for the full description of the quirk.
259
342
  // A structural list key (Enter / Tab / Shift+Tab on a per-li block) moves,
260
- // splits or removes the very <li> whose `.ed-li-text` currently has focus,
343
+ // splits or removes the very block whose `.ed-li-text` currently has focus,
261
344
  // so Chromium runs its unfocus step — firing a synchronous focusout — with
262
345
  // the run still in its PRE-mutation shape and `currentBurst` still live.
263
346
  // Unguarded, that focusout reaches resolveBurst(), whose li branch happily
@@ -281,6 +364,8 @@
281
364
  return fn();
282
365
  } finally {
283
366
  suppressLiFocusout = false;
367
+ // S1: indents (and therefore run boundaries) may have just moved.
368
+ refreshRunStarts();
284
369
  }
285
370
  }
286
371
 
@@ -598,6 +683,21 @@
598
683
  const blockId = Number(blockEl.getAttribute('data-block-id'));
599
684
  const block = blocks.find((b) => b.id === blockId);
600
685
  if (!block) return;
686
+ // Task 4 fix round 1 (Critical, second path): a block that owns no source
687
+ // line has an INVERTED range (endLine === startLine - 1) — see
688
+ // blockOwnsNoLine(). Everything below assumes a well-formed interval:
689
+ // extractBlockSource() of an inverted range is '', so the textarea opens
690
+ // EMPTY, and commit() -> commitEdit() -> commitRangeEdit(5, 4, text)
691
+ // INSERTS the text as a new line instead of replacing anything, leaving
692
+ // the clicked item untouched and a stray line in the file (measured:
693
+ // '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' -> '# Doc\n\n- a\n\nZZZ\n- - b\n').
694
+ // The only such blocks today are list items (RULING F-O independently
695
+ // forbids a textarea inside one), and they are never armed, so before
696
+ // Task 4 gave every block a ⠿ this was reached by a plain body click and
697
+ // stayed unreported. Guarded HERE rather than at the click delegator so
698
+ // every caller — the delegated degraded-block click, openRawViaGutter(),
699
+ // the burst-degrade path — is closed by one check.
700
+ if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
601
701
 
602
702
  const original = blockEl.innerHTML;
603
703
  const source = extractBlockSource(lines, block);
@@ -701,8 +801,7 @@
701
801
  // consistent with what the server actually has. Deliberately does
702
802
  // NOT call restore() — the editor (and the user's unsaved text)
703
803
  // stays open and visible; see the comment above.
704
- const rollback = stack.undo(lines);
705
- lines = rollback ? rollback.lines : prevLines;
804
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
706
805
  return false;
707
806
  }
708
807
  // Success: rerenderAll() already replaced the whole .content subtree
@@ -729,10 +828,11 @@
729
828
  // A block's rendered content is the .ed-block's single element child
730
829
  // (see lib/md2doc.js's editMode wrapper: `<div class="ed-block"
731
830
  // ...>${inner}</div>` where `inner` is exactly one <p>/<h#>/... tag).
732
- // Per-li exception (Task 6 / Phase 4): for data-block-type="li" the
733
- // .ed-block IS the <li> itself — its editable content is the child
734
- // <div class="ed-li-text"> (Task 4), not firstElementChild (which would
735
- // land on the optional .ed-li-check span or a nested <ul>/<ol>).
831
+ // Per-li exception (Task 6 / Phase 4): for data-block-type="li" the block's
832
+ // editable content is its child <div class="ed-li-text">, not
833
+ // firstElementChild S1 made that distinction load-bearing rather than
834
+ // merely defensive, since a flat list block's FIRST element child is always
835
+ // the .ed-li-marker span (and the .ed-li-check span may follow it).
736
836
  function blockContentEl(blockEl) {
737
837
  if (blockEl.getAttribute && blockEl.getAttribute('data-block-type') === 'li') {
738
838
  // Walk childNodes for the DIV with class ed-li-text (Task 4 shape).
@@ -783,58 +883,432 @@
783
883
  return !!tableEl && tableMd.serializeTable(tableEl).unsupported.length === 0;
784
884
  }
785
885
 
786
- // Task 6 (Phase 4): per-li eligibility check — build a one-item probe
787
- // UL/OL containing ONLY this li's non-list children (the .ed-li-check span
788
- // and the .ed-li-text div), then serialize it. Returns false when any
789
- // inline content is unsupported, so that li stays unarmed without affecting
790
- // its siblings. Uses the live li's own parent tag (UL or OL) so an ordered
791
- // task-item probes correctly as an ordered list.
792
- function canWysiwygForLi(liEl) {
793
- if (!liEl) return false;
794
- const parentTag = liEl.parentElement ? liEl.parentElement.nodeName : 'UL';
795
- const probe = document.createElement(parentTag);
796
- const liClone = liEl.cloneNode(false); // shallow: no nested ul/ol
797
- const kids = liEl.childNodes;
798
- for (let i = 0; i < kids.length; i++) {
799
- const k = kids[i];
800
- // Copy non-list children only (the check span and the text div).
801
- if (k.nodeName !== 'UL' && k.nodeName !== 'OL') {
802
- liClone.appendChild(k.cloneNode(true));
803
- }
886
+ // Task 6 (Phase 4): per-li eligibility check — serialize THIS block alone
887
+ // and refuse to arm it if anything in it cannot round-trip. Returns false
888
+ // when any inline content is unsupported, so one bad item never degrades its
889
+ // siblings.
890
+ //
891
+ // S1: no probe element is built any more. Pre-S1 this had to clone the <li>
892
+ // into a synthetic UL/OL because serializeList() only took a list ROOT;
893
+ // serializeBlocks() takes the block elements directly, so the live element
894
+ // is passed as a one-element run. Both halves of the result are checked:
895
+ // `unsupportedByLi` carries the per-block inline names, `unsupported` is its
896
+ // strict superset (it additionally collects stray TEXT, foreign children,
897
+ // and flat model, controller note T2-B — the 'P' of a loose item, which
898
+ // reaches the inline serializer as an ordinary unhandled element name).
899
+ //
900
+ // STRUCTURAL_ONLY names are excluded. 'MULTILINE' (a hard-wrapped item, which
901
+ // legitimately owns a RANGE of lines) is reported so the structural gate can
902
+ // refuse Tab / Enter on it, but spec §4.1 keeps text editing unaffected — and
903
+ // arming IS text editing. Roughly a fifth of real-world list items are
904
+ // hard-wrapped, so treating the flag as an arming veto turns whole documents
905
+ // read-only. Filtered by NAME LIST rather than by a hard-coded string so the
906
+ // serializer stays the single source of truth.
907
+ function armBlockingNames(unsupported) {
908
+ return unsupported.filter((n) => listMd.STRUCTURAL_ONLY_UNSUPPORTED.indexOf(n) === -1);
909
+ }
910
+
911
+ // A block that owns NO SOURCE LINE has nothing to edit, and arming it is
912
+ // actively destructive. Under same-line nesting ('- - b') the outer item's
913
+ // content begins with its child, so blockmap.js gives it
914
+ // endLine === startLine - 1 — an empty range, not an interval. The commit
915
+ // helpers do not special-case that: the per-li degrade path (taken whenever
916
+ // the run holds a loose or hard-wrapped item) hands
917
+ // editedBlock.startLine/endLine straight to commitRangeEdit(), whose
918
+ // replaceLines() computes `slice(0, start-1).concat(new, slice(end))` — with
919
+ // end < start those two slices OVERLAP, so the original line survives AND the
920
+ // new one is inserted:
921
+ //
922
+ // '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n- Z\n- - b\n'
923
+ //
924
+ // Refusing here closes every such path with one rule, instead of teaching
925
+ // seven commit helpers about a range shape none of them expect. The block is
926
+ // still rendered, still selectable, and its CHILD — which does own its line —
927
+ // stays fully editable.
928
+ function blockOwnsNoLine(blockEl) {
929
+ const raw = blockEl.getAttribute('data-block-id');
930
+ if (raw === null) return false; // provisional block: no record yet, not our call
931
+ const rec = blocks.find((b) => b.id === Number(raw));
932
+ return !!rec && rec.endLine < rec.startLine;
933
+ }
934
+
935
+ // Task 4 fix round 1: the message the two blockOwnsNoLine() guards above
936
+ // (openRawEditor / deleteBlockViaGutter) pass into the SHARED
937
+ // refuseStructuralListEdit() helper (defined further down, next to
938
+ // listRunSupportsStructuralEdit() — see its comment for why this is a
939
+ // parameter and not a second function). One constant so both call sites
940
+ // stay byte-identical instead of two hand-typed copies drifting apart.
941
+ const NO_SOURCE_LINE_MESSAGE = '此項目沒有自己的來源行,無法刪除或直接編輯';
942
+ // T7 fix round 1 (LOW-2): insertBlockBelow()'s own wording. A second
943
+ // constant rather than a reuse, because the one above NAMES the two
944
+ // operations it refuses — an insert that answered '無法刪除' would be
945
+ // telling the user something that is not true of the button they pressed.
946
+ const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入區塊';
947
+
948
+ // The list markers standing at the head of one source line, left to right.
949
+ // A same-line nest ('- 1. b') puts one marker per nesting level on the line;
950
+ // this reads them back so a re-emitted ancestor line can keep the bullet
951
+ // character / ordinal delimiter the file already uses. Stops at the first
952
+ // non-marker, so the last entry is the innermost item's own marker.
953
+ function sourceMarkerChain(line) {
954
+ const out = [];
955
+ let rest = typeof line === 'string' ? line : '';
956
+ for (;;) {
957
+ // The optional trailing group is a GFM task checkbox: it is CONTENT, not
958
+ // marker, but it sits between this marker and the next one, so the walk
959
+ // has to step over it or the chain stops at the first task item.
960
+ const m = /^(\s*)(?:([-*+])|(\d{1,9})([.)]))(\s+)(?:\[[ xX]\]\s+)?/.exec(rest);
961
+ if (!m) return out;
962
+ out.push({ bullet: m[2] || null, delim: m[4] || null });
963
+ rest = rest.slice(m[0].length);
964
+ }
965
+ }
966
+
967
+ // Rewrites a serialized marker to use the SOURCE's bullet char / ordinal
968
+ // delimiter, keeping the serializer's own width and ordinal. Both
969
+ // substitutions are single characters, so the marker's column count — which
970
+ // the child's indent prefix was computed against (spec §3.4) — cannot move.
971
+ //
972
+ // Why not just keep the serializer's canonical '-' / '1.': the degrade path
973
+ // rewrites ONE line and leaves its siblings' bytes alone, and marked starts a
974
+ // NEW list token at a bullet-char or delimiter change. Canonicalising this
975
+ // line alone therefore splits the surrounding list in two ('+ a' + '- …'),
976
+ // which is a visible change to items the user never edited.
977
+ // Takes the BULLET ('- ', '2. '), never the whole marker: a task item's
978
+ // marker is bullet + checkbox ('1. [ ] '), and the ordinal delimiter is then
979
+ // no longer at the end of the string for the substitution to find.
980
+ function bulletInSourceStyle(bullet, src) {
981
+ if (!src) return bullet;
982
+ if (src.bullet) return /^[-*+]/.test(bullet) ? src.bullet + bullet.slice(1) : bullet;
983
+ if (src.delim) return bullet.replace(/([.)])(\s*)$/, src.delim + '$2');
984
+ return bullet;
985
+ }
986
+
987
+
988
+ // Round 5 — the other half of the same-line-nesting problem. Refusing to ARM
989
+ // a zero-line block (above) keeps it from being the TARGET of a commit; it
990
+ // does nothing about the fact that its marker physically STANDS ON its
991
+ // child's source line. '- - b' is one line carrying two markers, and the
992
+ // child's own line range IS that line — so the per-li degrade path, which
993
+ // replaces [editedBlock.startLine, editedBlock.endLine] with only the lines
994
+ // lineMeta attributes to the edited block, overwrote every ancestor marker
995
+ // standing on it:
996
+ //
997
+ // '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n - bZ\n'
998
+ //
999
+ // The child lost its parent — a semantic change (the previous item swallows
1000
+ // it), not a reformat.
1001
+ //
1002
+ // So the replacement re-emits every such ancestor's MARKER on a line of its
1003
+ // own, ahead of the edited block's lines. That is the canonical form the
1004
+ // whole-run path already produces for a tight run ('- a\n-\n - b'), it
1005
+ // round-trips ('-\n - b' and '- - b' are the same tree to marked), and it
1006
+ // removes the zero-line shape from the file, so the ancestor becomes armable
1007
+ // afterwards.
1008
+ //
1009
+ // A content-free TASK ancestor is NOT in this list, and must not be: marked
1010
+ // only reads '[ ]' / '[x]' as a checkbox when content follows on the SAME
1011
+ // line, so a line of its own would downgrade it to literal text. Round 6
1012
+ // moved that case into the serializer, which now carries such an item as a
1013
+ // prefix on its child's line — so it emits no lineMeta entry of its own and
1014
+ // this walk never sees it, while the child's own emitted line (which the
1015
+ // caller slices anyway) already carries its marker. The two forms therefore
1016
+ // stay in one place: lib/editor/list-md.js decides, this only replays.
1017
+ //
1018
+ // MARKER ONLY, never the ancestor's emitted line: an item whose own content
1019
+ // resumes AFTER its child ('- - b' … ' tail') owns that content on a line
1020
+ // OUTSIDE this commit range, so emitting the serializer's full line for it
1021
+ // would duplicate the text. Its indent prefix and marker WIDTH come from
1022
+ // lineMeta's own record rather than from a guess — an ordered outer
1023
+ // contributes three columns, not two (spec §3.4's errata table) — while the
1024
+ // bullet CHARACTERS are taken back from the source line.
1025
+ //
1026
+ // Only ancestors whose startLine EQUALS the edited block's are collected:
1027
+ // that is what "stands on the same source line" means. A zero-line ancestor
1028
+ // higher up the document, or one belonging to an earlier sibling, names a
1029
+ // different line and must not be touched.
1030
+ function sharedMarkerLinesBefore(lineMeta, firstIdx, editedBlock) {
1031
+ const out = [];
1032
+ // The markers on that source line are its nesting levels, outermost first,
1033
+ // and the LAST one is the edited block's own — so a block's marker is found
1034
+ // by its DEPTH, not by counting the entries collected here. A task ancestor
1035
+ // occupies a marker on the line while contributing no lineMeta entry at
1036
+ // all (see above), which is exactly what a running counter would misalign.
1037
+ const chain = sourceMarkerChain(lines[editedBlock.startLine - 1]);
1038
+ const base = editedBlock.indent - (chain.length - 1);
1039
+ for (let k = 0; k < firstIdx; k++) {
1040
+ const m = lineMeta[k];
1041
+ if (!m || m.blockId === null || m.blockId === undefined) continue;
1042
+ const rec = blocks.find((b) => b.id === Number(m.blockId));
1043
+ if (!rec || rec.endLine >= rec.startLine) continue; // owns a line of its own
1044
+ if (rec.startLine !== editedBlock.startLine) continue;
1045
+ const marker = bulletInSourceStyle(m.marker, chain[rec.indent - base]);
1046
+ out.push((m.indentPrefix + marker).replace(/\s+$/, ''));
1047
+ }
1048
+ return out;
1049
+ }
1050
+
1051
+ function canWysiwygForLi(blockEl) {
1052
+ if (!blockEl) return false;
1053
+ if (blockOwnsNoLine(blockEl)) return false;
1054
+ const res = listMd.serializeBlocks([blockEl]);
1055
+ return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
1056
+ }
1057
+
1058
+ // ── S1: one run scan replaces every "walk up to the outermost UL/OL" ─────
1059
+ // The flat model has no <ul>/<ol> nodes left to walk up to: every list item
1060
+ // is a sibling `.ed-block[data-block-type="li"]` and its depth is
1061
+ // `data-indent`. Six helpers used to reach the enclosing list by DOM
1062
+ // ancestry; they all now go through the scan below.
1063
+ //
1064
+ // RUN (spec §3.8): the sibling items bound to the same parent item. Scanning
1065
+ // outward from a block, a run ends at the first li with a SMALLER indent, at
1066
+ // the first same-indent li whose data-list-type differs, or at the first
1067
+ // non-li block. DEEPER items never break the run — they are descendants of
1068
+ // one of its members.
1069
+ //
1070
+ // Rules (a)/(b)/(c) are bit-for-bit the rule lib/editor/list-md.js's
1071
+ // serializeBlocks() applies internally when it restarts an ordinal. If the
1072
+ // two ever disagree, the symptom is a wrong ordinal or a wrong commit range.
1073
+ //
1074
+ // Rule (d) — a run never crosses a `data-list-start="1"` — has no counterpart
1075
+ // in serializeBlocks() because it never needs one: §3.8's three rules cannot
1076
+ // tell two ADJACENT top-level lists of the same type apart (marked emits a
1077
+ // fresh list token for a bullet-char change, so '- a' followed by '* c' is
1078
+ // two lists whose blocks are all indent 0 / type ul), and before flattening
1079
+ // the two <ul> roots carried that distinction. lib/md2doc.js's renderer
1080
+ // stamps the boundary; this scan honours it, so serializeBlocks() is never
1081
+ // handed a span that straddles two lists and the two can never disagree on a
1082
+ // span that actually reaches it.
1083
+
1084
+ function allBlockEls() {
1085
+ return Array.prototype.slice.call(contentEl.querySelectorAll('.ed-block'));
1086
+ }
1087
+
1088
+ function liAttrs(el) {
1089
+ if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
1090
+ return {
1091
+ el: el,
1092
+ indent: Number(el.getAttribute('data-indent')) || 0,
1093
+ listType: el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul',
1094
+ listStart: el.getAttribute('data-list-start') === '1',
1095
+ };
1096
+ }
1097
+
1098
+ // The nearest li `.ed-block` ancestor of `node` (inclusive), or null.
1099
+ // Text nodes are legal input (Selection boundary points are usually text
1100
+ // nodes), so this walks parentNode by hand rather than using .closest().
1101
+ function closestLiBlock(node) {
1102
+ let n = node;
1103
+ while (n) {
1104
+ if (n.nodeType === 1 && n.getAttribute &&
1105
+ n.classList && n.classList.contains('ed-block') &&
1106
+ n.getAttribute('data-block-type') === 'li') return n;
1107
+ n = n.parentNode;
804
1108
  }
805
- probe.appendChild(liClone);
806
- return listMd.serializeList(probe).unsupported.length === 0;
1109
+ return null;
807
1110
  }
808
1111
 
809
- // Walk up from `el` to find the outermost UL/OL whose parent is NOT a <li>
810
- // (i.e. the list-run root the UL/OL that is directly inside .ed-block or
811
- // the document, not a nested sub-list inside another li).
812
- function listRunRootOf(el) {
813
- let cur = el;
814
- let root = null;
815
- while (cur) {
816
- if (cur.nodeName === 'UL' || cur.nodeName === 'OL') {
817
- if (!cur.parentElement || cur.parentElement.nodeName !== 'LI') {
818
- root = cur;
1112
+ // Spec §3.8's run of `blockEl`, as an array of block elements in document
1113
+ // order. Empty when `blockEl` is not a live li block.
1114
+ function runBlocksOf(blockEl) {
1115
+ const self = liAttrs(blockEl);
1116
+ if (!self) return [];
1117
+ const all = allBlockEls();
1118
+ const i = all.indexOf(blockEl);
1119
+ if (i < 0) return [];
1120
+ const out = [blockEl];
1121
+ if (!self.listStart) {
1122
+ for (let k = i - 1; k >= 0; k--) {
1123
+ const a = liAttrs(all[k]);
1124
+ if (!a || a.indent < self.indent) break;
1125
+ if (a.indent === self.indent) {
1126
+ if (a.listType !== self.listType) break;
1127
+ out.unshift(all[k]);
819
1128
  }
1129
+ // Rule (d), scoped to THIS depth. A list-start DEEPER than `self` is a
1130
+ // nested sublist hanging off one of the run's own members — it must not
1131
+ // end the run it lives inside. Only a list-start at `self`'s depth (or
1132
+ // shallower, already handled above) is a boundary for this run.
1133
+ if (a.listStart && a.indent <= self.indent) break;
1134
+ }
1135
+ }
1136
+ for (let k = i + 1; k < all.length; k++) {
1137
+ const a = liAttrs(all[k]);
1138
+ if (!a || a.indent < self.indent) break;
1139
+ if (a.listStart && a.indent <= self.indent) break; // rule (d), this depth
1140
+ if (a.indent === self.indent) {
1141
+ if (a.listType !== self.listType) break;
1142
+ out.push(all[k]);
820
1143
  }
821
- cur = cur.parentElement;
822
1144
  }
823
- return root;
1145
+ return out;
1146
+ }
1147
+
1148
+ // The COMMIT UNIT for any list edit: the contiguous block span made up of
1149
+ // the OUTERMOST run reachable from `blockEl` plus every descendant of that
1150
+ // run's members. Returned in document order; empty when `blockEl` is not a
1151
+ // live li block.
1152
+ //
1153
+ // Why the outermost run and not `blockEl`'s own: serializeBlocks() rebuilds
1154
+ // the marker-width stack (spec §3.4) as it walks, so a span that STARTS at
1155
+ // indent 2 has no width recorded for depths 0 and 1 and emits its first line
1156
+ // with NO indent at all — i.e. committing a nested run on its own would
1157
+ // promote it to top level and destroy the nesting. Starting at the outermost
1158
+ // depth is also exactly what the pre-S1 code did (listRunRootOf() walked up
1159
+ // to the UL/OL whose parent was not an <li>, i.e. the whole top-level list),
1160
+ // so the committed byte ranges are unchanged by the flattening.
1161
+ //
1162
+ // What DID change — deliberately, per spec §3.8 rule (b) — is that two
1163
+ // adjacent top-level lists of DIFFERENT type are two spans. Pre-S1 they were
1164
+ // already two separate <ul>/<ol> roots, so this is the same behaviour
1165
+ // expressed without the containers.
1166
+ function listRunOf(blockEl) {
1167
+ const self = liAttrs(blockEl);
1168
+ if (!self) return [];
1169
+ const all = allBlockEls();
1170
+ const i = all.indexOf(blockEl);
1171
+ if (i < 0) return [];
1172
+ // 1. Walk back to the shallowest li that still owns `blockEl` — its
1173
+ // outermost ancestor item. Stops at the first non-li block.
1174
+ // The walk stops on its own the moment it reaches indent 0, which is where
1175
+ // every list token's first item sits — so it can never cross into the
1176
+ // PREVIOUS list, and rule (d) needs no break of its own here. It must NOT
1177
+ // be skipped for a list-start block at indent > 0: that is a NESTED list's
1178
+ // first item, whose outermost ancestor is still above it in the same list,
1179
+ // and returning a nested-only span would emit the run with no indent
1180
+ // prefix at all — i.e. de-nest it on commit.
1181
+ let anchor = i;
1182
+ let anchorIndent = self.indent;
1183
+ for (let k = i - 1; k >= 0 && anchorIndent > 0; k--) {
1184
+ const a = liAttrs(all[k]);
1185
+ if (!a) break;
1186
+ if (a.indent < anchorIndent) { anchor = k; anchorIndent = a.indent; }
1187
+ }
1188
+ // 2. That ancestor's own §3.8 run gives the span's first and last MEMBER.
1189
+ const run = runBlocksOf(all[anchor]);
1190
+ if (!run.length) return [];
1191
+ const startIdx = all.indexOf(run[0]);
1192
+ let endIdx = all.indexOf(run[run.length - 1]);
1193
+ // 3. Extend past the last member to cover its subtree.
1194
+ // `a.indent <= anchorIndent` already stops at the next list token's first
1195
+ // item (every token starts at indent 0 relative to its own nesting), so no
1196
+ // separate rule-(d) break belongs here — and a DEEPER list-start is a
1197
+ // nested sublist of the last run member, which the span must include.
1198
+ for (let k = endIdx + 1; k < all.length; k++) {
1199
+ const a = liAttrs(all[k]);
1200
+ if (!a || a.indent <= anchorIndent) break;
1201
+ endIdx = k;
1202
+ }
1203
+ return all.slice(startIdx, endIdx + 1);
1204
+ }
1205
+
1206
+ // Returns { startLine, endLine, firstId } for a run span (as returned by
1207
+ // listRunOf()), looked up in state.blocks by data-block-id. Document order is
1208
+ // monotonic in block id, so first/last suffices.
1209
+ //
1210
+ // A span may contain a PROVISIONAL block — splitListItemAtCaret()'s new item,
1211
+ // which has no data-block-id because it does not exist in `lines` yet. Those
1212
+ // are skipped: the range is the source lines the span currently OCCUPIES, and
1213
+ // a provisional block occupies none. (Pre-S1 this fell out for free because
1214
+ // the provisional <li> carried no `ed-block` class and the querySelectorAll
1215
+ // never saw it; the flat model needs it to be a real block element, so the
1216
+ // skip has to be explicit.) Returns null when no member is resolvable.
1217
+ function runRangeOfBlocks(state, runEls) {
1218
+ if (!runEls || !runEls.length) return null;
1219
+ const resolved = [];
1220
+ runEls.forEach((el) => {
1221
+ const raw = el.getAttribute('data-block-id');
1222
+ if (raw === null) return;
1223
+ const b = state.blocks.find((x) => x.id === Number(raw));
1224
+ if (b) resolved.push(b);
1225
+ });
1226
+ if (!resolved.length) return null;
1227
+ const firstBlock = resolved[0];
1228
+ const lastBlock = resolved[resolved.length - 1];
1229
+ return { startLine: firstBlock.startLine, endLine: lastBlock.endLine, firstId: firstBlock.id };
1230
+ }
1231
+
1232
+ // Convenience wrapper kept at the old call shape: takes any node inside a
1233
+ // list item and resolves its own commit span's line range.
1234
+ function runRangeOf(state, node) {
1235
+ return runRangeOfBlocks(state, listRunOf(closestLiBlock(node)));
1236
+ }
1237
+
1238
+ // Re-derives `data-run-start` across the whole document. The attribute is
1239
+ // pure CSS chrome (Task 5 resets the ordered counter on it) and no
1240
+ // serializer reads it, but a structural key changes indents WITHOUT a
1241
+ // re-render for the duration of the commit's round trip, so leaving it stale
1242
+ // would show wrong ordinals for that window. Same rule as the renderer's
1243
+ // liRunStartsHere() and serializeBlocks()'s own ordinal restart.
1244
+ function refreshRunStarts() {
1245
+ const all = allBlockEls();
1246
+ let prev = null;
1247
+ const types = [];
1248
+ all.forEach((el) => {
1249
+ const a = liAttrs(el);
1250
+ if (!a) { prev = null; types.length = 0; return; }
1251
+ // Rule (d): a new list token always opens a new run, and closes the runs
1252
+ // open AT ITS OWN DEPTH AND DEEPER — never the shallower ones, which
1253
+ // belong to the list this token is nested inside. data-list-start is
1254
+ // renderer-owned and never rewritten here: it is the only carrier of the
1255
+ // boundary between two adjacent same-type list tokens.
1256
+ if (a.listStart) types.length = Math.min(types.length, a.indent);
1257
+ const isStart = !prev || a.indent > prev.indent || types[a.indent] !== a.listType;
1258
+ for (let k = types.length - 1; k > a.indent; k--) types[k] = undefined;
1259
+ types[a.indent] = a.listType;
1260
+ prev = a;
1261
+ if (isStart) el.setAttribute('data-run-start', '1');
1262
+ else el.removeAttribute('data-run-start');
1263
+ });
824
1264
  }
825
1265
 
826
- // Returns { startLine, endLine, firstId } from the first and last .ed-block
827
- // li descendants of rootEl, looked up in state.blocks by their data-block-id
828
- // (document order is monotonic Task 3 asserts it so first/last suffices).
829
- function runRangeOf(state, rootEl) {
830
- const liEls = Array.prototype.slice.call(rootEl.querySelectorAll('.ed-block'));
831
- if (!liEls.length) return null;
832
- const firstId = Number(liEls[0].getAttribute('data-block-id'));
833
- const lastId = Number(liEls[liEls.length - 1].getAttribute('data-block-id'));
834
- const firstBlock = state.blocks.find((b) => b.id === firstId);
835
- const lastBlock = state.blocks.find((b) => b.id === lastId);
836
- if (!firstBlock || !lastBlock) return null;
837
- return { startLine: firstBlock.startLine, endLine: lastBlock.endLine, firstId };
1266
+ // The single place a block's depth is written: `data-indent` is what every
1267
+ // serializer and scan reads, and `--ed-indent` is the CSS mirror the flat
1268
+ // renderer emits alongside it. Writing one without the other makes the
1269
+ // screen disagree with the model for the length of a commit round trip.
1270
+ function setBlockIndent(blockEl, indent) {
1271
+ blockEl.setAttribute('data-indent', String(indent));
1272
+ blockEl.style.setProperty('--ed-indent', String(indent));
1273
+ }
1274
+
1275
+ // Spec §3.4, applied to the DOM: hand the (already-mutated) commit span to
1276
+ // the pure clamp in lib/editor/indent-clamp.js and write back whatever it
1277
+ // says. `opBlockEl` is the block the gesture moved, `opOldIndent` its indent
1278
+ // BEFORE the move (the spec's global convention).
1279
+ //
1280
+ // Scoped to the commit SPAN, never to the whole document: the span is
1281
+ // exactly the set of blocks the following commit re-serializes, so a clamp
1282
+ // confined to it can never widen the byte range an operation touches. On a
1283
+ // document that was legal to begin with — which is every document, since
1284
+ // data-indent is derived from marked's own nesting — this is a no-op, and it
1285
+ // is meant to be. It is here so the ONE definition of "legal indent" lives
1286
+ // in one testable place instead of being re-derived by each key handler.
1287
+ //
1288
+ // Blocks are matched by data-block-id, so a PROVISIONAL block (a split's new
1289
+ // item, id-less) is passed through untouched rather than being addressed by
1290
+ // position.
1291
+ // `opts` is handed straight to clampIndents() — today only `{ removed: true }`,
1292
+ // used by the ⠿ delete below, which must clamp the span it is ABOUT to take a
1293
+ // member out of. The span passed in therefore still CONTAINS `opBlockEl` (it
1294
+ // has to: `opIndex` is an index into it, and rule 2's scope starts after it);
1295
+ // clampIndents() reports no indent for a removed block, so the write-back
1296
+ // below never touches the element that is on its way out.
1297
+ function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1298
+ if (!indentClamp || !spanEls || !spanEls.length) return;
1299
+ const opIndex = spanEls.indexOf(opBlockEl);
1300
+ if (opIndex < 0) return;
1301
+ const model = spanEls.map((el, i) => ({
1302
+ id: i, // index-as-id: the span IS the universe here
1303
+ type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
1304
+ indent: Number(el.getAttribute('data-indent')) || 0,
1305
+ }));
1306
+ indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {}).forEach((r) => {
1307
+ const el = spanEls[r.blockId];
1308
+ if (el && (Number(el.getAttribute('data-indent')) || 0) !== r.indent) {
1309
+ setBlockIndent(el, r.indent);
1310
+ }
1311
+ });
838
1312
  }
839
1313
 
840
1314
  function placeCaretAtEnd(el) {
@@ -955,8 +1429,7 @@
955
1429
  lines = result.lines;
956
1430
  const okRender = await safeRerenderAll();
957
1431
  if (!okRender) {
958
- const rollback = stack.undo(lines);
959
- lines = rollback ? rollback.lines : prevLines;
1432
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
960
1433
  }
961
1434
  }
962
1435
 
@@ -1008,19 +1481,34 @@
1008
1481
  editEl.setAttribute('contenteditable', 'true');
1009
1482
  editEl.classList.add('ed-wys-armed');
1010
1483
  } else if (blockType === 'li') {
1011
- // Task 6 (Phase 4): per-li arming. Each <li class="ed-block"> is
1012
- // armed independently: only its own .ed-li-text div becomes
1484
+ // Task 6 (Phase 4): per-li arming. Each
1485
+ // `.ed-block[data-block-type="li"]` is armed independently: only its
1486
+ // own .ed-li-text div becomes
1013
1487
  // contenteditable when canWysiwygForLi holds, so one unsupported item
1014
1488
  // does not degrade its siblings.
1015
1489
  if (editEl && canWysiwygForLi(blockEl)) {
1016
1490
  editEl.setAttribute('contenteditable', 'true');
1017
1491
  editEl.classList.add('ed-wys-armed');
1018
1492
  }
1019
- // MUST return here a <li> gets NO ⠿/+ gutter chrome (overlay
1020
- // chrome is P4). The unconditional appendChild calls below would
1021
- // inject <button> children into the <li>, which list-md.js would
1022
- // classify as content and inline-md.js would flag 'BUTTON' unsupported,
1023
- // silently degrading every list item. Do NOT remove this return.
1493
+ // S1: the li now gets the same as every other block, at every
1494
+ // indent depth. This is only safe because list-md.js's
1495
+ // serializeBlocks() skips the chrome BY CLASS TOKEN (its LI_CHROME
1496
+ // allowlist) if that allowlist is ever narrowed, every <button>
1497
+ // here reaches the inline serializer as content, every li reports
1498
+ // 'BUTTON' unsupported and the WHOLE document degrades read-only.
1499
+ //
1500
+ // S2 Task 7 (§6's S1 note item 3, 「+ 對 li 在 S1 隱藏,S2 解除」): the
1501
+ // + is back, in the same order as every other type. What S1 was
1502
+ // waiting for is now true — insertBlockBelow() gates a li anchor on
1503
+ // listRunSupportsStructuralEdit(), anchors the insertion at the end of
1504
+ // the anchor's SUBTREE (so a parent's children are never straddled),
1505
+ // and takes the 清單 kind through the run's own re-serialization, which
1506
+ // is where §3.4's marker-width stack lives. '.ed-insert' is already in
1507
+ // list-md.js's LI_CHROME allowlist, so this adds no NEW element type
1508
+ // for serializeBlocks() to report as unsupported — the `clean` probe in
1509
+ // the S1 li-gutter scenario is what actually holds that.
1510
+ blockEl.appendChild(buildGutterInsertButton());
1511
+ blockEl.appendChild(buildGutterHandle());
1024
1512
  return;
1025
1513
  } else if (editEl && blockType === 'table' && canWysiwygForTable(editEl)) {
1026
1514
  // Task 5 (Phase 3): table cells armed PERMANENTLY at arm time
@@ -1065,6 +1553,16 @@
1065
1553
  el.className = 'ed-handle';
1066
1554
  el.textContent = '⠿';
1067
1555
  el.setAttribute('aria-label', '區塊選項');
1556
+ // v2.11.1: a <button> is a sequential focus stop, and there is one of
1557
+ // these plus one + standing immediately after EVERY block — so any Tab
1558
+ // that reaches the browser walks straight into gutter chrome, which is the
1559
+ // most jarring shape of the two escape classes fixed above. Both are
1560
+ // mouse-only affordances with no keyboard contract of their own (the ⠿
1561
+ // menu is opened by click; nothing here is reachable or operable by
1562
+ // keyboard today), so they are removed from the tab order rather than
1563
+ // given one they do not have. tabindex="-1" keeps them programmatically
1564
+ // and click-focusable, so `.ed-handle:focus { opacity: 1 }` still works.
1565
+ el.setAttribute('tabindex', '-1');
1068
1566
  // Deliberately NOT wired with its own addEventListener here (see the
1069
1567
  // paragraph above) — including for Final-review Finding 5a's mousedown
1070
1568
  // preventDefault() (see wireBlockSelection()'s delegated 'mousedown'
@@ -1077,51 +1575,55 @@
1077
1575
  return el;
1078
1576
  }
1079
1577
 
1080
- // The single shared ⠿ menu (heading ± / MD 原始碼 / close) built once,
1081
- // moved into whichever block's DOM the user opened it on, same pattern as
1082
- // `selToolbar` elsewhere in this file. `gutterMenuBlockEl` names which
1083
- // block it's currently open for.
1578
+ // The single shared ⠿ menu (spec §3.7: 轉換成 › / 建立副本 / 刪除 / MD 原始碼)
1579
+ // — built once, moved into whichever block's DOM the user opened it on,
1580
+ // same pattern as `selToolbar` elsewhere in this file. `gutterMenuBlockEl`
1581
+ // names which block it's currently open for. Because the node is a
1582
+ // SINGLETON, every per-type visibility decision has to be re-applied on
1583
+ // each open (see toggleGutterMenu below), never set once at build time.
1584
+ //
1585
+ // S2: the heading ± pair is gone from here — §3.5 moved that gesture onto
1586
+ // Tab / Shift+Tab, which call the same changeHeadingDepth() this menu used
1587
+ // to. So is ✕: §3.7 closes the menu by Esc or an outside click, both of
1588
+ // which were already wired (the document-level keydown / click handlers
1589
+ // further down), so removing the button removes a button, not a capability.
1084
1590
  let gutterMenuBlockEl = null;
1085
- let gutterMenuMinus, gutterMenuPlus;
1591
+ let gutterMenuConvert, gutterMenuDuplicate, gutterMenuDelete, gutterMenuMd;
1592
+ // The 轉換成 submenu: a SECOND singleton, built lazily on demand and torn
1593
+ // down with the menu. It carries `ed-handle-menu` as well as its own class
1594
+ // so it inherits the panel's whole visual language AND so the document-level
1595
+ // outside-click handler's `closest('.ed-handle-menu')` exclusion covers it
1596
+ // without a second selector.
1597
+ let convertSubmenu = null;
1086
1598
 
1087
1599
  function buildGutterMenu() {
1088
1600
  const el = document.createElement('div');
1089
1601
  el.className = 'ed-handle-menu';
1090
1602
 
1091
- gutterMenuMinus = document.createElement('button');
1092
- gutterMenuMinus.type = 'button';
1093
- gutterMenuMinus.className = 'ed-handle-menu-btn';
1094
- gutterMenuMinus.textContent = '';
1095
- gutterMenuMinus.setAttribute('aria-label', 'Decrease heading level');
1096
- gutterMenuMinus.addEventListener('click', (e) => {
1097
- e.stopPropagation();
1098
- const blockEl = gutterMenuBlockEl;
1099
- closeGutterMenu();
1100
- changeHeadingDepth(blockEl, -1);
1101
- });
1603
+ function item(label, aria, onClick) {
1604
+ const b = document.createElement('button');
1605
+ b.type = 'button';
1606
+ b.className = 'ed-handle-menu-btn';
1607
+ b.textContent = label;
1608
+ b.setAttribute('aria-label', aria);
1609
+ b.addEventListener('click', onClick);
1610
+ el.appendChild(b);
1611
+ return b;
1612
+ }
1102
1613
 
1103
- gutterMenuPlus = document.createElement('button');
1104
- gutterMenuPlus.type = 'button';
1105
- gutterMenuPlus.className = 'ed-handle-menu-btn';
1106
- gutterMenuPlus.textContent = '+';
1107
- gutterMenuPlus.setAttribute('aria-label', 'Increase heading level');
1108
- gutterMenuPlus.addEventListener('click', (e) => {
1614
+ // 轉換成 is the one item that does NOT close the menu — it grows a
1615
+ // submenu, and a second press folds it back up.
1616
+ gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
1109
1617
  e.stopPropagation();
1110
- const blockEl = gutterMenuBlockEl;
1111
- closeGutterMenu();
1112
- changeHeadingDepth(blockEl, 1);
1618
+ if (convertSubmenu) { closeConvertSubmenu(); return; }
1619
+ openConvertSubmenu(gutterMenuConvert);
1113
1620
  });
1114
1621
 
1115
- const mdBtn = document.createElement('button');
1116
- mdBtn.type = 'button';
1117
- mdBtn.className = 'ed-handle-menu-btn';
1118
- mdBtn.textContent = 'MD 原始碼';
1119
- mdBtn.setAttribute('aria-label', 'Switch to raw markdown edit');
1120
- mdBtn.addEventListener('click', (e) => {
1622
+ gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
1121
1623
  e.stopPropagation();
1122
1624
  const blockEl = gutterMenuBlockEl;
1123
1625
  closeGutterMenu();
1124
- openRawViaGutter(blockEl);
1626
+ duplicateBlockViaMenu(blockEl);
1125
1627
  });
1126
1628
 
1127
1629
  // §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
@@ -1131,38 +1633,65 @@
1131
1633
  // just calling it from here too, not touching its implementation) via
1132
1634
  // deleteBlockViaGutter() below, which resolves any open burst first
1133
1635
  // (requirement: structural ops always go through switchAwayFrom()).
1134
- const deleteBtn = document.createElement('button');
1135
- deleteBtn.type = 'button';
1136
- deleteBtn.className = 'ed-handle-menu-btn';
1137
- deleteBtn.textContent = '刪除';
1138
- deleteBtn.setAttribute('aria-label', 'Delete this block');
1139
- deleteBtn.addEventListener('click', (e) => {
1636
+ gutterMenuDelete = item('刪除', 'Delete this block', (e) => {
1140
1637
  e.stopPropagation();
1141
1638
  const blockEl = gutterMenuBlockEl;
1142
1639
  closeGutterMenu();
1143
1640
  deleteBlockViaGutter(blockEl);
1144
1641
  });
1145
1642
 
1146
- const closeBtn = document.createElement('button');
1147
- closeBtn.type = 'button';
1148
- closeBtn.className = 'ed-handle-menu-btn';
1149
- closeBtn.textContent = '✕';
1150
- closeBtn.setAttribute('aria-label', 'Close menu');
1151
- closeBtn.addEventListener('click', (e) => {
1643
+ gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
1152
1644
  e.stopPropagation();
1645
+ const blockEl = gutterMenuBlockEl;
1153
1646
  closeGutterMenu();
1647
+ openRawViaGutter(blockEl);
1154
1648
  });
1155
1649
 
1156
- el.appendChild(gutterMenuMinus);
1157
- el.appendChild(gutterMenuPlus);
1158
- el.appendChild(mdBtn);
1159
- el.appendChild(deleteBtn);
1160
- el.appendChild(closeBtn);
1161
1650
  return el;
1162
1651
  }
1163
1652
  const gutterMenu = buildGutterMenu();
1164
1653
 
1654
+ // Spec §3.2's twelve v1 targets, rendered as a panel anchored to the right
1655
+ // of the 轉換成 row. The panel is a CHILD of the menu, and the menu is
1656
+ // `position: absolute`, so it is the submenu's own containing block and
1657
+ // `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
1658
+ // no viewport arithmetic, and the panel travels with the menu when the menu
1659
+ // is moved into another block.
1660
+ function openConvertSubmenu(anchorBtn) {
1661
+ closeConvertSubmenu();
1662
+ const sub = document.createElement('div');
1663
+ sub.className = 'ed-handle-menu ed-handle-submenu';
1664
+ convertMd.CONVERT_TARGETS.forEach((t) => {
1665
+ const b = document.createElement('button');
1666
+ b.type = 'button';
1667
+ b.className = 'ed-handle-menu-btn';
1668
+ b.textContent = t.label;
1669
+ b.setAttribute('aria-label', 'Convert to ' + t.id);
1670
+ b.addEventListener('click', (e) => {
1671
+ e.stopPropagation();
1672
+ const blockEl = gutterMenuBlockEl;
1673
+ closeGutterMenu();
1674
+ convertBlockViaMenu(blockEl, t.id);
1675
+ });
1676
+ sub.appendChild(b);
1677
+ });
1678
+ sub.style.top = anchorBtn.offsetTop + 'px';
1679
+ anchorBtn.parentNode.appendChild(sub);
1680
+ convertSubmenu = sub;
1681
+ }
1682
+
1683
+ function closeConvertSubmenu() {
1684
+ if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
1685
+ }
1686
+
1165
1687
  function closeGutterMenu() {
1688
+ // The submenu lives INSIDE the menu, so removing the menu already detaches
1689
+ // it — but `convertSubmenu` would keep pointing at the detached node and
1690
+ // the next 轉換成 press would read it as "already open" and merely fold a
1691
+ // panel nobody can see. Same stale-singleton hazard `gutterMenuBlockEl`
1692
+ // documents just below, and the reason rerenderAll()'s reset list needs no
1693
+ // second entry: it already calls this.
1694
+ closeConvertSubmenu();
1166
1695
  gutterMenu.remove();
1167
1696
  gutterMenuBlockEl = null;
1168
1697
  }
@@ -1176,11 +1705,40 @@
1176
1705
  // on a DIFFERENT block would otherwise leave two floating menus up at
1177
1706
  // once. closeInsertMenu() is idempotent (safe even when nothing is open).
1178
1707
  closeInsertMenu();
1708
+ // A menu re-opened on another block must never inherit the previous
1709
+ // block's expanded submenu — it was built against THAT block and its
1710
+ // targets close over `gutterMenuBlockEl` at click time, so a stale panel
1711
+ // is a panel that converts the wrong block.
1712
+ closeConvertSubmenu();
1179
1713
  gutterMenuBlockEl = blockEl;
1180
1714
  const blockType = blockEl.getAttribute('data-block-type');
1181
- const isHeading = blockType === 'heading';
1182
- gutterMenuMinus.hidden = !isHeading;
1183
- gutterMenuPlus.hidden = !isHeading;
1715
+ // Spec §7: a table block has no 轉換成 at all (there is no target that
1716
+ // could carry a table's cells, and every one of the twelve would destroy
1717
+ // them).
1718
+ //
1719
+ // 'hr' and 'html' are withheld for a different, measured reason: the
1720
+ // gesture would LIE. convert-md strips a block's marker to get its
1721
+ // content, and an <hr> has no content — its source line IS the marker.
1722
+ // Measured: 'hr' → 項目符號列表 writes '- ---', which marked re-lexes
1723
+ // as an hr again, so the file's bytes change, the block type does not,
1724
+ // and no banner is shown. 'hr' → 文字 is a byte no-op, also silent.
1725
+ // An 'html' block is raw passthrough for the same reason: there is no
1726
+ // marker to strip and no content to re-host. Nothing is lost either way,
1727
+ // but an item that appears to work and does nothing is worse than an
1728
+ // item that is not offered.
1729
+ gutterMenuConvert.hidden = (blockType === 'table' || blockType === 'hr' || blockType === 'html');
1730
+ gutterMenuDuplicate.hidden = false;
1731
+ gutterMenuDelete.hidden = false;
1732
+ // RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
1733
+ // phased measure. openRawEditor() replaces the block's innerHTML with a
1734
+ // <textarea>, and a li is one line of a run that is serialized as a whole
1735
+ // — a textarea inside it is content the serializer cannot represent, and
1736
+ // restore() would have to rebuild the marker/check/text chrome from a
1737
+ // string. Every other block type keeps it: the menu is a SINGLETON moved
1738
+ // between blocks, so this must be reset on every open, not set once.
1739
+ // (test/editor-reader-rebind.test.js drives raw-edit through this button
1740
+ // by its exact text on a paragraph.)
1741
+ gutterMenuMd.hidden = (blockType === 'li');
1184
1742
  blockEl.appendChild(gutterMenu);
1185
1743
  }
1186
1744
 
@@ -1197,6 +1755,8 @@
1197
1755
  el.className = 'ed-insert';
1198
1756
  el.textContent = '+';
1199
1757
  el.setAttribute('aria-label', '插入區塊');
1758
+ // Not a tab stop — see buildGutterHandle() above for the whole reason.
1759
+ el.setAttribute('tabindex', '-1');
1200
1760
  return el;
1201
1761
  }
1202
1762
 
@@ -1392,26 +1952,117 @@
1392
1952
  // recovery idiom used throughout this file), THEN acts.
1393
1953
  async function insertBlockBelow(blockEl, kind) {
1394
1954
  if (!blockEl) return;
1395
- const blockId = Number(blockEl.getAttribute('data-block-id'));
1955
+ // T7: captured BEFORE switchAwayFrom(), because that is what can renumber
1956
+ // the ids — see captureBlockIdentity()'s comment.
1957
+ const identity = captureBlockIdentity(blockEl);
1958
+ // S2 Task 7: the FOURTH and last call site of the hole 轉換 (Task 2), 刪除
1959
+ // and 建立副本 (Task 6) already closed, and the one that was latent only
1960
+ // because a li had no + to press. Finding 5a's delegated mousedown
1961
+ // preventDefault() names '.ed-insert' as well as '.ed-handle', so the
1962
+ // burst survives the press and the commit that lands inside
1963
+ // switchAwayFrom() below can be a rewrite of THIS block's own source —
1964
+ // in which case reresolveBlockEl()'s source fingerprint is guaranteed to
1965
+ // miss, because WE are the reason the source changed, and the gesture is
1966
+ // dropped with '文件已更新,請重試這個操作' having done nothing. The
1967
+ // narrowed re-resolve (startLine + type, no fingerprint) is used ONLY
1968
+ // when the session that just committed was this block's OWN;
1969
+ // reresolveBlockEl() keeps its fingerprint for everybody else.
1970
+ const selfSession = ownsOpenSession(blockEl);
1396
1971
  const ok = await switchAwayFrom();
1397
1972
  if (!ok) return;
1398
1973
  let liveBlockEl = blockEl;
1399
1974
  if (!document.body.contains(blockEl)) {
1400
- liveBlockEl = document.querySelector('.ed-block[data-block-id="' + blockId + '"]');
1401
- if (!liveBlockEl) return;
1975
+ liveBlockEl = reresolveBlockEl(identity) ||
1976
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
1977
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
1978
+ }
1979
+ // T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
1980
+ // for the same reason and against the same LIVE block. A block that owns
1981
+ // no source line has endLine === startLine - 1 (blockOwnsNoLine()), and
1982
+ // commitBlockInsertion() inserts at `endLine + 1` — which for an inverted
1983
+ // range is the line ABOVE the block, i.e. inside whatever precedes it. It
1984
+ // also reads `state.lines[endLine]` to decide the trailing blank, so it
1985
+ // samples a line belonging to somebody else. Latent today only because a
1986
+ // li block grows no + until S2; S2 is next, and a guard that has to be
1987
+ // remembered later is a guard that will not be.
1988
+ if (blockOwnsNoLine(liveBlockEl)) {
1989
+ refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
1990
+ return;
1402
1991
  }
1403
- const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
1404
- const block = blocks.find((b) => b.id === liveBlockId);
1405
- if (!block) return;
1406
1992
  const newLines = BLOCK_SKELETONS[kind];
1407
1993
  if (!newLines) return;
1994
+
1995
+ // ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
1996
+ //
1997
+ // Two things change, and both were measured against the pure core rather
1998
+ // than reasoned from the plan:
1999
+ //
2000
+ // 1. THE INSERTION POINT IS THE END OF THE ANCHOR'S SUBTREE, not the
2001
+ // anchor's own last line. This is the ruling §4.3 already made for
2002
+ // 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
2003
+ // non-list kind safe here. Measured on ['# Doc','','- alpha',
2004
+ // ' - child',' - grand','']: anchored on `child`,
2005
+ // commitBlockInsertion() with the 段落 skeleton yields
2006
+ // '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
2007
+ // marked lexes ' - grand' after a paragraph as an INDENTED CODE
2008
+ // BLOCK — the grandchild's content is gone. Anchored on the end of the
2009
+ // subtree the same gesture yields
2010
+ // '# Doc\n\n- alpha\n - child\n - grand\n\n<ZWSP>\n', whose
2011
+ // token list holds no `code` at all. No kind needs to refuse.
2012
+ //
2013
+ // 2. THE 清單 KIND DOES NOT GO THROUGH commitBlockInsertion() AT ALL.
2014
+ // That function ALWAYS writes a leading blank line (see its own
2015
+ // comment), and for a list that blank is the §4.3 rule 2 defect:
2016
+ // measured, '# Doc\n\n- alpha\n - child\n\n -\n' has a NESTED
2017
+ // list with loose === true, so every item of it grows a <p>,
2018
+ // serializeBlocks() pushes 'P' for each and the run degrades read-only
2019
+ // with no banner. Same fork 建立副本 hit in Task 6, and the same answer:
2020
+ // route the li through its own run's re-serialization, which emits no
2021
+ // blank at all, re-runs §3.8's renumbering, and — the point of carry 2
2022
+ // — takes the new item's indent prefix from the serializer's own
2023
+ // marker-width stack instead of re-deriving it. There is deliberately
2024
+ // no `indentPrefixOf()` here: `' '.repeat(indent * 2)` is what §3.4
2025
+ // forbids, and even reading lineMeta's `indentPrefix` back would be a
2026
+ // second copy of an arithmetic list-md.js already owns.
2027
+ //
2028
+ // The §4.3 run-wide gate applies on the way in, like every other
2029
+ // structural op. `columnOnly` is the honest option: no EXISTING item's
2030
+ // content or line count is rewritten — the only bytes that move in a
2031
+ // bystander are its marker and leading columns (§3.8 renumbering, applied
2032
+ // as §3.4's colDelta by the carryOver replay), which is exactly the
2033
+ // criterion listRunSupportsStructuralEdit() documents. Without it a
2034
+ // single hard-wrapped item anywhere in the run would veto the +, which
2035
+ // on this repo's own CHANGELOG.md is every run.
2036
+ let anchorEl = liveBlockEl;
2037
+ if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2038
+ const run = listRunOf(liveBlockEl);
2039
+ if (!run.length) return;
2040
+ if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
2041
+ refuseStructuralListEdit();
2042
+ return;
2043
+ }
2044
+ const subtree = subtreeBlocksAfter(liveBlockEl,
2045
+ Number(liveBlockEl.getAttribute('data-indent')) || 0);
2046
+ anchorEl = subtree.length ? subtree[subtree.length - 1] : liveBlockEl;
2047
+ if (kind === 'list') { await insertListItemAfter(liveBlockEl, run, anchorEl); return; }
2048
+ // The subtree's last member is a li like any other, so it can own no
2049
+ // source line for the same reason the anchor could — and it is the block
2050
+ // commitBlockInsertion() is about to read `endLine` and `lines[endLine]`
2051
+ // off. Re-checked against the block actually used.
2052
+ if (blockOwnsNoLine(anchorEl)) {
2053
+ refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
2054
+ return;
2055
+ }
2056
+ }
2057
+ const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
2058
+ const block = blocks.find((b) => b.id === liveBlockId);
2059
+ if (!block) return;
1408
2060
  const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
1409
2061
  const prevLines = lines;
1410
2062
  lines = result.lines;
1411
2063
  const okRender = await safeRerenderAll();
1412
2064
  if (!okRender) {
1413
- const rollback = stack.undo(lines);
1414
- lines = rollback ? rollback.lines : prevLines;
2065
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1415
2066
  return;
1416
2067
  }
1417
2068
  // §10-gap fix (review): mark the freshly-inserted block "pristine" —
@@ -1423,33 +2074,856 @@
1423
2074
  await focusInsertedBlock(result.newStartLine, kind);
1424
2075
  }
1425
2076
 
2077
+
2078
+ // S2 Task 7 — the li half of +. The new item is spliced into the run's own
2079
+ // span and the WHOLE span is re-serialized over the run's line range: one
2080
+ // commitRangeEdit, therefore one undo op, no leading blank line (so the run
2081
+ // stays TIGHT — see insertBlockBelow()'s note 2), §3.8's renumbering for
2082
+ // free, and the new item's indent prefix straight out of list-md.js's
2083
+ // marker-width stack.
2084
+ //
2085
+ // `lastEl` is the end of the anchor's subtree, so the new item is the
2086
+ // anchor's SIBLING and lands after the anchor's children rather than
2087
+ // between them.
2088
+ async function insertListItemAfter(liEl, run, lastEl) {
2089
+ // Captured BEFORE the new item enters the span: it carries no
2090
+ // data-block-id (it does not exist in `lines` yet), so runRangeOfBlocks()
2091
+ // would skip it — but on an insertion after the span's LAST member the
2092
+ // derived range would then silently stop one line short of nothing at all.
2093
+ // Passing the pre-mutation range is the same discipline duplicateListItem()
2094
+ // uses, for the same reason.
2095
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2096
+ if (!range) return;
2097
+ const at = run.indexOf(lastEl);
2098
+ if (at < 0) return;
2099
+ const newLi = buildProvisionalListItem(liEl);
2100
+ const span = run.slice(0, at + 1).concat([newLi], run.slice(at + 1));
2101
+ mutateListRun(() => {
2102
+ lastEl.parentNode.insertBefore(newLi, lastEl.nextSibling);
2103
+ });
2104
+ // No `mutatedEl`: nothing that already existed had its content rewritten,
2105
+ // so every existing member is replayed from the file's own bytes and a
2106
+ // '~5px' stays a '~5px'. The new item has no id and is skipped by
2107
+ // bystanderCarryOver() on its own.
2108
+ const carry = bystanderCarryOver(span);
2109
+ // The SAME map commitListStructure() is about to use — runLineOfBlock()
2110
+ // is an index INTO the lines that map produces, so rebuilding it here
2111
+ // would only usually be the same answer.
2112
+ const focusLine = runLineOfBlock(span, newLi, carry);
2113
+ await commitListStructure(span, focusLine, false,
2114
+ { presetRange: range, carryOver: carry });
2115
+ }
2116
+
2117
+ // A brand-new, empty list item that will become real on the next commit —
2118
+ // the same provisional-block shape splitListItemAtCaret() builds (no
2119
+ // data-block-id: it owns no source line yet, and the commit's rerenderAll()
2120
+ // replaces it with a real, server-numbered block).
2121
+ //
2122
+ // It inherits `data-list-type` and `data-task` from the anchor, NOT the
2123
+ // 清單 menu label's implied bullet. §3.8 rule (b) is why: a different
2124
+ // data-list-type ENDS the run, so a bullet dropped into an ordered run
2125
+ // would split it into three list tokens and renumber what is left. Enter on
2126
+ // a list item (splitListItemAtCaret above) already inherits both, so this is
2127
+ // the established answer rather than a new one.
2128
+ //
2129
+ // `data-list-start` is deliberately NOT copied: it is the only carrier of
2130
+ // "marked opened a new list token here" (§3.8 rule (d)) and a new sibling
2131
+ // inside an existing run is never a token boundary — copying it would
2132
+ // restart the ordinal counter mid-run.
2133
+ function buildProvisionalListItem(anchorLi) {
2134
+ const el = document.createElement('div');
2135
+ el.className = 'ed-block';
2136
+ el.setAttribute('data-block-type', 'li');
2137
+ el.setAttribute('data-list-type', anchorLi.getAttribute('data-list-type') || 'ul');
2138
+ const isTask = anchorLi.getAttribute('data-task') === '1';
2139
+ el.setAttribute('data-task', isTask ? '1' : '0');
2140
+ setBlockIndent(el, Number(anchorLi.getAttribute('data-indent')) || 0);
2141
+ const marker = document.createElement('span');
2142
+ marker.className = 'ed-li-marker';
2143
+ marker.setAttribute('aria-hidden', 'true');
2144
+ el.appendChild(marker);
2145
+ // A fresh item is never checked — nothing in the anchor's line says
2146
+ // otherwise. buildLiCheckbox() is the one place that markup lives, so the
2147
+ // renderer and this stay byte-identical (see its own comment).
2148
+ if (isTask) el.appendChild(buildLiCheckbox());
2149
+ const text = document.createElement('div');
2150
+ text.className = 'ed-li-text';
2151
+ el.appendChild(text);
2152
+ return el;
2153
+ }
2154
+
2155
+ // ── S2 spec §4.3: 轉換成 ────────────────────────────────────────────────
2156
+ //
2157
+ // The written gesture order is fixed: closeGutterMenu() (the menu item's own
2158
+ // click handler already did it) -> switchAwayFrom() -> re-locate the block by
2159
+ // startLine -> operate. The SOURCE is the resolved `lines`, never the DOM.
2160
+ //
2161
+ // Why line-level rather than "mutate the DOM and re-serialize the run like
2162
+ // every other structural op": list-md.js's serializeBlocks() pushes the
2163
+ // uppercased block type into `unsupported` for any non-`li` block inside the
2164
+ // span it is given, which is EXACTLY the shape a conversion produces — every
2165
+ // li -> heading would hit the degrade path and refuse itself. Reading `lines`
2166
+ // also means the inline content is never re-serialized, so escapeText() never
2167
+ // runs over it and a `~5px` in the converted block stays `~5px`.
2168
+ async function convertBlockViaMenu(blockEl, target) {
2169
+ if (!blockEl || !target) return;
2170
+ const identity = captureBlockIdentity(blockEl);
2171
+ // Whether the session switchAwayFrom() is about to resolve belongs to THIS
2172
+ // block. Finding 5a's mousedown preventDefault() deliberately keeps a dirty
2173
+ // burst alive across the ⠿ press, so the commit that lands inside
2174
+ // switchAwayFrom() below can be a rewrite of the very block we are about to
2175
+ // convert — in which case reresolveBlockEl()'s source fingerprint is
2176
+ // guaranteed to miss, because WE are the reason the source changed. That is
2177
+ // not a dropped gesture; startLine + type still name the block, and the
2178
+ // fingerprint's job (proving an UNRELATED commit did not move somebody else
2179
+ // into this slot) is done by those two here.
2180
+ const selfSession = ownsOpenSession(blockEl);
2181
+ const ok = await switchAwayFrom();
2182
+ if (!ok) return;
2183
+ let liveBlockEl = blockEl;
2184
+ if (!document.body.contains(blockEl)) {
2185
+ liveBlockEl = reresolveBlockEl(identity) ||
2186
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2187
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2188
+ }
2189
+ // Same refusal deleteBlockViaGutter() makes, for the same reason: a block
2190
+ // that owns no source line has an INVERTED range (endLine === startLine-1),
2191
+ // and every commit helper handed one does something plausible and wrong.
2192
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2193
+ const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2194
+ const rec = blocks.find((b) => b.id === liveBlockId);
2195
+ if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2196
+ const kind = liveBlockEl.getAttribute('data-block-type');
2197
+
2198
+ // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
2199
+ // listRunSupportsStructuralEdit() BEFORE any mutation, the same door
2200
+ // Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
2201
+ // is exactly what listRunOf() returns (the outermost run PLUS every
2202
+ // descendant of its members) — see deleteListItemViaGutter()'s own note.
2203
+ //
2204
+ // ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
2205
+ // not-yet-implemented refusals below and, further down, of stripMarker():
2206
+ // a multi-line li must report §4.1's 「此清單含不支援的格式,無法調整結構」
2207
+ // and not convert-md.js's per-block 「此區塊的格式無法轉換」, which is what
2208
+ // it would get if stripMarker() saw it first (that function refuses a
2209
+ // multi-line li too, for its own, narrower reason). The runtime scenario
2210
+ // 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so
2211
+ // it is what notices if this order is ever flipped.
2212
+ //
2213
+ // A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's
2214
+ // own text or line count, so a multi-line li refuses as a TARGET while
2215
+ // remaining a perfectly good bystander.
2216
+ let liRun = null;
2217
+ if (kind === 'li') {
2218
+ liRun = listRunOf(liveBlockEl);
2219
+ if (!liRun.length) return;
2220
+ if (!listRunSupportsStructuralEdit(liRun, liveBlockEl)) { refuseStructuralListEdit(); return; }
2221
+ }
2222
+
2223
+ // S2 Task 3: li → a LIST target. The block stays a li, so the run stays a
2224
+ // run and the existing re-serialization machinery applies unchanged.
2225
+ if (kind === 'li' && convertMd.targetIsList(target)) {
2226
+ await convertListItemType(liveBlockEl, liRun, target);
2227
+ return;
2228
+ }
2229
+ // S2 Task 4: li → a NON-list target. The item LEAVES the run, so the span
2230
+ // has to be rebuilt in three pieces and §4.3 rule 1's blank lines put
2231
+ // between them — the plain path below would leave the converted line
2232
+ // mid-list with no separator and lazy continuation would swallow it into
2233
+ // the item above (measured, §4.3 rule 1).
2234
+ if (kind === 'li') {
2235
+ await convertListItemAway(liveBlockEl, liRun, rec, target);
2236
+ return;
2237
+ }
2238
+ // S2 Task 5: a non-list block BECOMES a li, so §4.3 rule 2's looseness
2239
+ // policy applies — eat the separator to an adjacent run of the same list
2240
+ // type, or the merged list goes LOOSE and every item of it degrades
2241
+ // read-only. Same rule, same helper the li → li path above uses.
2242
+ if (convertMd.targetIsList(target)) {
2243
+ await convertBlockIntoList(liveBlockEl, rec, kind, target);
2244
+ return;
2245
+ }
2246
+
2247
+ const src = lines.slice(rec.startLine - 1, rec.endLine);
2248
+ const stripped = convertMd.stripMarker(src, kind);
2249
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2250
+ const newLines = convertMd.emitAs(stripped.content, target, {});
2251
+
2252
+ const result = commitRangeEdit({ lines, blocks, stack },
2253
+ rec.startLine, rec.endLine, newLines.join('\n'));
2254
+ // Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
2255
+ // onto the undo stack either, so there is nothing to render or roll back.
2256
+ if (result.op === null) return;
2257
+ const prevLines = lines;
2258
+ lines = result.lines;
2259
+ const okRender = await safeRerenderAll();
2260
+ if (!okRender) {
2261
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2262
+ }
2263
+ }
2264
+
2265
+ // Is the editor session switchAwayFrom() would resolve open on THIS block?
2266
+ // Both shapes count — the always-on WYSIWYG burst and the older raw-edit /
2267
+ // table-cell `activeEditor` — because resolveOpenSession() commits either.
2268
+ function ownsOpenSession(blockEl) {
2269
+ if (currentBurst && currentBurst.blockEl === blockEl) return true;
2270
+ if (activeEditor && activeEditor.blockEl === blockEl) return true;
2271
+ return false;
2272
+ }
2273
+
2274
+ // The narrowed re-resolve for the case above: startLine and type must still
2275
+ // match, but the source is allowed to differ because our own switchAwayFrom()
2276
+ // just rewrote it. Deliberately NOT folded into reresolveBlockEl() — every
2277
+ // other caller of that function needs the fingerprint, and a shared helper
2278
+ // that sometimes skips it is the shape that lets a future call site act on a
2279
+ // block the user never pointed at.
2280
+ function reresolveBlockElAfterSelfCommit(identity) {
2281
+ if (!identity) return null;
2282
+ const at = blocks.find((b) => b.startLine === identity.startLine);
2283
+ if (!at || at.type !== identity.type) return null;
2284
+ return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
2285
+ }
2286
+
2287
+ // S2 Task 6 — 建立副本 (§4.3).
2288
+ //
2289
+ // The copy is inserted after the block's ENTIRE SUBTREE, never after its own
2290
+ // line. The spec records the measurement and it reproduces here:
2291
+ // after the subtree '- a\n - a1\n- a\n- b\n'
2292
+ // -> items ['- a\n - a1\n', '- a\n', '- b'] (a keeps its child)
2293
+ // after a's own line '- a\n- a\n - a1\n- b\n'
2294
+ // -> items ['- a\n', '- a\n - a1\n', '- b'] (the COPY got a1)
2295
+ // Both lex cleanly and both are tight, so nothing but the item boundaries
2296
+ // tells them apart — which is why the runtime scenario asserts the raws.
2297
+ //
2298
+ // TWO commit paths, and which one each case takes was MEASURED against the
2299
+ // pure core, not reasoned from symmetry:
2300
+ //
2301
+ // * a NON-li block goes through commitBlockInsertion(), which IS this
2302
+ // operation and already owns the blank-line policy (see :170-179).
2303
+ // Measured on ['# Doc','','alpha',''] with body ['alpha']:
2304
+ // '# Doc\n\nalpha\n\nalpha\n'.
2305
+ // * a li does NOT — this is the trap. commitBlockInsertion() ALWAYS
2306
+ // inserts a leading blank line. Measured on ['# Doc','','- a','- b','']
2307
+ // with body ['- a'] it returns '# Doc\n\n- a\n\n- a\n\n- b\n', and
2308
+ // marked.lexer() reports that as ONE list with loose === true. Every item
2309
+ // of a loose list renders as <p>, serializeBlocks() pushes 'P' for each of
2310
+ // them (list-md.js:462) and the WHOLE run degrades read-only with no
2311
+ // banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
2312
+ // conversion. A li therefore duplicates through its own RUN's
2313
+ // re-serialization (duplicateListItem() below), which emits no blank at
2314
+ // all and re-runs §3.8's renumbering on the way.
2315
+ //
2316
+ // Neither path re-serializes the copy's CONTENT: the non-li path slices
2317
+ // `lines`, and the li path carries the clone through bystanderCarryOver()
2318
+ // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
2319
+ // for it and only re-states the marker. Both keep a `~5px` a `~5px`.
2320
+ async function duplicateBlockViaMenu(blockEl) {
2321
+ if (!blockEl) return;
2322
+ const identity = captureBlockIdentity(blockEl);
2323
+ // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
2324
+ // alive across the ⠿ press, so the commit that lands inside
2325
+ // switchAwayFrom() below can be a rewrite of the very block we are about
2326
+ // to duplicate — in which case reresolveBlockEl()'s SOURCE fingerprint is
2327
+ // guaranteed to miss, because WE are the reason the source changed. Same
2328
+ // narrowed recovery convertBlockViaMenu() uses, for the same reason; see
2329
+ // reresolveBlockElAfterSelfCommit().
2330
+ const selfSession = ownsOpenSession(blockEl);
2331
+ const ok = await switchAwayFrom();
2332
+ if (!ok) return;
2333
+ let liveBlockEl = blockEl;
2334
+ if (!document.body.contains(blockEl)) {
2335
+ liveBlockEl = reresolveBlockEl(identity) ||
2336
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2337
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2338
+ }
2339
+ // Same refusal deleteBlockViaGutter() and convertBlockViaMenu() make, for
2340
+ // the same reason: a block that owns no source line has an INVERTED range
2341
+ // (endLine === startLine - 1) and every commit helper handed one does
2342
+ // something plausible and wrong.
2343
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2344
+ const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2345
+ const rec = blocks.find((b) => b.id === liveBlockId);
2346
+ if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2347
+
2348
+ if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2349
+ await duplicateListItem(liveBlockEl);
2350
+ return;
2351
+ }
2352
+
2353
+ const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId,
2354
+ lines.slice(rec.startLine - 1, rec.endLine));
2355
+ if (result.op === null) return;
2356
+ const prevLines = lines;
2357
+ lines = result.lines;
2358
+ if (!(await safeRerenderAll())) {
2359
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2360
+ }
2361
+ }
2362
+
2363
+ // The li half of 建立副本. The copy is spliced into the run's own span and the
2364
+ // WHOLE span is re-serialized over the run's line range — one commitRangeEdit,
2365
+ // therefore one undo op (§4.3: 建立副本與刪除均為單一 undo), no leading blank, and
2366
+ // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
2367
+ // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
2368
+ // bravo').
2369
+ async function duplicateListItem(liEl) {
2370
+ const run = listRunOf(liEl);
2371
+ if (!run.length) return;
2372
+ // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
2373
+ // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
2374
+ // which is exactly what listRunOf() returns (the outermost run PLUS every
2375
+ // descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
2376
+ // it adds the item's lines over again), so a multi-line li refuses as a
2377
+ // TARGET while remaining a perfectly good bystander.
2378
+ if (!listRunSupportsStructuralEdit(run, liEl)) { refuseStructuralListEdit(); return; }
2379
+ // Captured BEFORE the copy enters the span. The copy carries the
2380
+ // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
2381
+ // its bytes rather than re-escape them — so runRangeOfBlocks() would
2382
+ // resolve it to the original's record, and on a duplicate of the span's
2383
+ // LAST member that silently re-states the range's end line.
2384
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2385
+ if (!range) return;
2386
+
2387
+ // §4.3, measured: after the SUBTREE, not after the item's own line.
2388
+ // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
2389
+ // following blocks at a STRICTLY greater indent — and listRunOf() already
2390
+ // covers every one of them, so the insertion point is always inside `run`.
2391
+ const subtree = subtreeBlocksAfter(liEl, Number(liEl.getAttribute('data-indent')) || 0);
2392
+ const lastEl = subtree.length ? subtree[subtree.length - 1] : liEl;
2393
+ const at = run.indexOf(lastEl);
2394
+ if (at < 0) return;
2395
+
2396
+ const copy = liEl.cloneNode(true);
2397
+ // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
2398
+ // list token here" (§3.8 rule (d)) and serializeBlocks() resets the
2399
+ // ordinal counter on it. A copy is never a token boundary — it sits inside
2400
+ // the run it was cloned from — so a clone that kept the attribute would
2401
+ // restart the numbering: duplicating the first item of '1. alpha / 2.
2402
+ // bravo' emits '1. alpha / 1. alpha / 2. bravo'.
2403
+ copy.removeAttribute('data-list-start');
2404
+ const span = run.slice(0, at + 1).concat([copy], run.slice(at + 1));
2405
+ mutateListRun(() => {
2406
+ lastEl.parentNode.insertBefore(copy, lastEl.nextSibling);
2407
+ });
2408
+ // No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
2409
+ // DOM, the copy included. The map is what keeps both lines byte-identical
2410
+ // to the file — dropping it entirely re-serializes them through
2411
+ // inline-md.js's escapeText() and a '~5px' comes back '\~5px' (measured;
2412
+ // the 'the copy is not re-escaped' scenario is what notices).
2413
+ //
2414
+ // ⚠ MEASURED, and worth stating because it is NOT the usual contract:
2415
+ // passing `liEl` here would be INERT, unlike at every other call site.
2416
+ // bystanderCarryOver() keys the map on the block ID, and the copy carries
2417
+ // the ORIGINAL's id — so the copy's own pass re-adds the very entry the
2418
+ // exclusion just skipped. The argument is omitted because it is wrong in
2419
+ // principle (nothing was mutated), not because a test would catch it.
2420
+ //
2421
+ // That shared id is also what makes ONE map entry serve both lines, while
2422
+ // list-md.js re-states each line's marker from that element's OWN
2423
+ // attributes — which is the §3.8 renumbering, and which is also how the
2424
+ // copy keeps its 型態 / 縮排 / 勾選狀態.
2425
+ await commitListStructure(span, null, false,
2426
+ { presetRange: range, carryOver: bystanderCarryOver(span) });
2427
+ }
1426
2428
  // Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
1427
2429
  // — unchanged, see its own comment — to any block type, not just an
1428
2430
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
1429
2431
  // precondition as insertBlockBelow() above.
1430
2432
  async function deleteBlockViaGutter(blockEl) {
1431
2433
  if (!blockEl) return;
1432
- const blockId = Number(blockEl.getAttribute('data-block-id'));
2434
+ // T7: same startLine + source-fingerprint recovery as insertBlockBelow()
2435
+ // above, and the stake here is higher — an id shift used to make this
2436
+ // delete a DIFFERENT block's lines, with the ⠿ menu the user pressed
2437
+ // pointing at a block that survived.
2438
+ const identity = captureBlockIdentity(blockEl);
2439
+ // S2 Task 6: the SAME hole convertBlockViaMenu() closed in Task 2, which
2440
+ // this path never got. Finding 5a's mousedown preventDefault() keeps a
2441
+ // dirty burst alive across the ⠿ press, so the commit that lands inside
2442
+ // switchAwayFrom() below can be a rewrite of the very block being deleted
2443
+ // — and reresolveBlockEl()'s fingerprint is that block's SOURCE, so it is
2444
+ // guaranteed to MISS, because WE are the reason the source changed.
2445
+ // Measured before this line existed: edit a paragraph, press ⠿ without
2446
+ // blurring, 刪除 — the gesture was dropped with '文件已更新,請重試這個操作'
2447
+ // and the block stayed on screen. The narrowed re-resolve (startLine +
2448
+ // type, no fingerprint) is used ONLY when the session that just committed
2449
+ // was this block's OWN; reresolveBlockEl() keeps its fingerprint for
2450
+ // everybody else.
2451
+ const selfSession = ownsOpenSession(blockEl);
1433
2452
  const ok = await switchAwayFrom();
1434
2453
  if (!ok) return;
1435
2454
  let liveBlockEl = blockEl;
1436
2455
  if (!document.body.contains(blockEl)) {
1437
- liveBlockEl = document.querySelector('.ed-block[data-block-id="' + blockId + '"]');
1438
- if (!liveBlockEl) return;
2456
+ liveBlockEl = reresolveBlockEl(identity) ||
2457
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2458
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
1439
2459
  }
1440
2460
  const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
1441
2461
  const block = blocks.find((b) => b.id === liveBlockId);
1442
2462
  if (!block) return;
2463
+ // Task 4 fix round 1 (Critical): refuse a block that owns no source line.
2464
+ // Its range is INVERTED (endLine === startLine - 1 — see blockOwnsNoLine()),
2465
+ // and commitListBlockRemoval() -> commitRangeRemoval() does not guard
2466
+ // `endLine >= startLine`: with sl=5, el=4 the blank-line absorption reads
2467
+ // state.lines[sl - 2], which for an inverted range is a blank line
2468
+ // belonging to a DIFFERENT block, finds it blank, and deletes it. Nothing
2469
+ // visible happens — no error, no banner, the .ed-block count is unchanged
2470
+ // — but the file loses a separator (measured: '# Doc\n\n- a\n\n- - b\n'
2471
+ // -> '# Doc\n\n- a\n- - b\n').
2472
+ //
2473
+ // Same predicate canWysiwygForLi() already refuses on, so "cannot be
2474
+ // armed" and "cannot be deleted" stay one decision. The ⠿ itself is NOT
2475
+ // gated on it: Task 4 requires every block to have a handle, and hiding it
2476
+ // would trade that requirement for a delete-path bug. Re-checked against
2477
+ // the LIVE block, after switchAwayFrom()'s possible re-render renumbered
2478
+ // the ids.
2479
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2480
+ // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
2481
+ // splice. S1 is what first put a ⠿ on a li, and the plain range removal
2482
+ // below corrupts a list three separate ways — see
2483
+ // deleteListItemViaGutter() for the measurements and the routing.
2484
+ if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2485
+ await deleteListItemViaGutter(liveBlockEl);
2486
+ return;
2487
+ }
1443
2488
  const result = commitListBlockRemoval({ lines, blocks, stack }, liveBlockId);
1444
2489
  const prevLines = lines;
1445
2490
  lines = result.lines;
1446
2491
  const okRender = await safeRerenderAll();
1447
2492
  if (!okRender) {
1448
- const rollback = stack.undo(lines);
1449
- lines = rollback ? rollback.lines : prevLines;
2493
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2494
+ }
2495
+ }
2496
+
2497
+ // Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
2498
+ //
2499
+ // Up to S1 this path did not exist: armEditables() returned before the
2500
+ // gutter chrome for a li, so the menu (and therefore its 刪除) was
2501
+ // unreachable on one. S1 gives every block a ⠿, which connected the
2502
+ // block-type-agnostic commitListBlockRemoval() to the most natural gesture
2503
+ // in the new UI — and that function deletes ONE BLOCK'S LINE RANGE, which is
2504
+ // the wrong unit for a list in three separate ways, all measured with a real
2505
+ // gesture plus Ctrl+S:
2506
+ //
2507
+ // * its blank-line absorption is correct for a standalone block and wrong
2508
+ // for a run member: the blank ABOVE the run still separates the
2509
+ // SURVIVORS from whatever precedes them. 'Para.\n\n1. a\n2. b\n3. c\n'
2510
+ // came back 'Para.\n2. b\n3. c\n' — one paragraph, three items gone,
2511
+ // no banner.
2512
+ // * no §3.4 clamp, so a child outlives its parent at an indent nothing
2513
+ // anchors: '# T\n\n- a\n - deep\n- b\n' left ' - deep' four
2514
+ // columns after a heading, i.e. an INDENTED CODE BLOCK.
2515
+ // * no re-serialization of the survivors, so an ordered run kept its old
2516
+ // ordinals on disk ('2. b / 3. c') while the CSS counter showed 1,2 —
2517
+ // the file and the screen disagreeing until somebody types in that run.
2518
+ //
2519
+ // The sequence is convertEmptyTopLevelLiToParagraph()'s, not a new one:
2520
+ // capture the span's range BEFORE mutating (removing the last item leaves
2521
+ // commitListStructure() nothing to derive it from), clamp, remove, then
2522
+ // commit the re-serialized survivors over that range with every one of them
2523
+ // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
2524
+ // its marker and its leading columns.
2525
+ async function deleteListItemViaGutter(liEl) {
2526
+ const run = listRunOf(liEl);
2527
+ if (!run.length) return;
2528
+ // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
2529
+ // exactly what listRunOf() returns (the outermost run PLUS every
2530
+ // descendant of its members), so the deeper runs this delete is about to
2531
+ // re-indent are covered, not just the target's own. Deleting is NOT
2532
+ // column-only: it removes the target's lines outright, so a multi-line
2533
+ // target refuses per §4.1.
2534
+ if (!listRunSupportsStructuralEdit(run, liEl)) { refuseStructuralListEdit(); return; }
2535
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2536
+ if (!range) return;
2537
+ const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2538
+ const survivors = run.filter((el) => el !== liEl);
2539
+ mutateListRun(() => {
2540
+ // Clamp FIRST, while `liEl` is still in the span: `{ removed: true }` is
2541
+ // what tells the pure function that this block can no longer anchor
2542
+ // anything, and rule 2's scope is measured from its position.
2543
+ applyIndentClamp(run, liEl, oldIndent, { removed: true });
2544
+ removeListItem(liEl);
2545
+ });
2546
+ // No `mutatedEl`: the deleted block is not among the survivors, and every
2547
+ // survivor's own bytes are exactly what the file already holds. The marker
2548
+ // is re-stated by the serializer regardless of the carry-over, which is
2549
+ // what renumbers the run (§3.8) and applies the clamped indent.
2550
+ await commitListStructure(survivors, null, false,
2551
+ { presetRange: range, carryOver: bystanderCarryOver(survivors) });
2552
+ }
2553
+
2554
+ // S2 Task 3 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a li.
2555
+ //
2556
+ // The block STAYS a li, which is what makes this the easy list shape: the
2557
+ // run stays a run, so nothing here has to reach for convert-md.js at all.
2558
+ // The two attributes are flipped in the DOM and the whole span goes back
2559
+ // through commitListStructure() exactly like every other structural list op
2560
+ // — which is also what re-runs §3.8's renumbering (a type change splits the
2561
+ // run at this item, so both halves restart at 1) and §3.4's marker-width
2562
+ // stack (a child under a '1. ' parent moves from column 2 to column 3).
2563
+ //
2564
+ // `run` and the §4.3 gate are the CALLER's (convertBlockViaMenu): the gate
2565
+ // has to sit ahead of every other refusal so a multi-line li reports §4.1's
2566
+ // banner, and re-deriving the run here would walk `allBlockEls()` twice.
2567
+ //
2568
+ // MEASURED, and it contradicts the plan, which pinned blank lines either
2569
+ // side of the converted item: none are emitted and none are needed.
2570
+ // `marked.lexer('- alpha\n1. bravo\n- charlie\n')` already returns THREE
2571
+ // list tokens — a marker-type change interrupts a list on its own. §4.3
2572
+ // rule 1's blank line is about li → NON-LIST (Task 4), where a bare
2573
+ // paragraph line really would be swallowed as a lazy continuation.
2574
+ //
2575
+ // `data-list-type` and `data-task` are §4.1's two ORTHOGONAL axes, so 待辦
2576
+ // 清單 is 'ul' + task and switching to 項目符號列表 removes the checkbox
2577
+ // rather than merely unchecking it — a plain bullet has nowhere in the
2578
+ // markdown to store checkedness.
2579
+ // ── §4.3 rule 2 (the looseness trap), in its 2026-08-30 revised form ──────
2580
+ //
2581
+ // MEASURED, twice, and the second measurement is what the revision is about:
2582
+ // marked.lexer('- a\n- b\n- c\n') → ONE list, loose === false
2583
+ // marked.lexer('- a\n- b\n\n- c\n') → ONE list, loose === TRUE
2584
+ // A blank line between two lists of the SAME marker type does not separate
2585
+ // them; it makes the single list they form LOOSE. Every item of a loose list
2586
+ // renders as `<p>…</p>`, serializeBlocks() reports 'P' for each of them
2587
+ // (list-md.js:56-70 documents the ruling, :462 is the push) and the whole run
2588
+ // degrades read-only — with NO banner, because nothing refused anything.
2589
+ //
2590
+ // The spec's original wording keyed the rule on 「來源是非清單」. That is
2591
+ // wrong, and the counter-example was measured in the live editor during S2
2592
+ // Task 3:
2593
+ // start '# Doc\n\n- a\n\n1. b\n' → list|space|list, BOTH tight
2594
+ // gesture 轉換成 › 項目符號列表 on `b`
2595
+ // bytes '# Doc\n\n- a\n\n- b\n' → ONE list, loose === true
2596
+ // after every structural gesture on that run refuses with §4.1's banner
2597
+ // One li → li conversion froze a run the user could no longer restructure.
2598
+ // The ruling therefore keys on 「轉換結果是 li」: whatever the source was, if
2599
+ // the RESULT is a li, the separator to a same-type neighbour must be eaten.
2600
+ //
2601
+ // Two consequences that are not obvious:
2602
+ //
2603
+ // 1. The blank line being eaten lies OUTSIDE listRunOf()'s span — it belongs
2604
+ // BETWEEN two runs, to neither. So the commit range has to be widened
2605
+ // past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
2606
+ // two places where that happens (§3.4's 2026-08-30 erratum); the other is
2607
+ // §4.3 rule 1's edge blanks in convertListItemAway() above.
2608
+ //
2609
+ // 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
2610
+ // into a healthy one freezes the healthy one too — and declining to merge
2611
+ // is no escape, because once the marker types match, markdown merges the
2612
+ // two whether or not the separator survives (it just goes loose instead).
2613
+ // The only correct answer there is to refuse the whole gesture, which is
2614
+ // what `ok: false` means.
2615
+ //
2616
+ // ⚠ The question is asked about the neighbour's RUN, not about the
2617
+ // neighbour BLOCK — and this contradicts the plan's Task 5 sketch, which
2618
+ // tests `previousBlockEl`'s own data-indent/data-list-type. MEASURED:
2619
+ // '- alpha\n - beta\n\n- gamma\n' → ONE list, loose === true
2620
+ // The block above the separator is `beta` at indent 1, so the sketch's
2621
+ // predicate says "no merge" and commits exactly those degrading bytes. The
2622
+ // list `gamma` actually joins is ALPHA's, and looseness is a property of the
2623
+ // whole list token — so the comparison must be against listRunOf(neighbour)'s
2624
+ // HEAD, which is the run's top-level identity.
2625
+ //
2626
+ // `range` is the commit range as the caller's own machinery derived it;
2627
+ // `spanEls` the block span that range covers (used only to find the
2628
+ // neighbouring blocks); `headAttrs` / `tailAttrs` the {listType, indent} the
2629
+ // span's first and last TOP-LEVEL lines will carry AFTER the conversion —
2630
+ // which is why they are passed in rather than read here: for li → li this
2631
+ // runs BEFORE the DOM mutation, so a refusal never has a half-mutated run to
2632
+ // undo.
2633
+ function widenRangeForListMerge(range, spanEls, headAttrs, tailAttrs) {
2634
+ const all = allBlockEls();
2635
+ const first = spanEls[0];
2636
+ const last = spanEls[spanEls.length - 1];
2637
+ const i = all.indexOf(first);
2638
+ const j = all.indexOf(last);
2639
+ let startLine = range.startLine;
2640
+ let endLine = range.endLine;
2641
+ const sides = [
2642
+ { el: i > 0 ? all[i - 1] : null, attrs: headAttrs, back: true },
2643
+ { el: (j >= 0 && j + 1 < all.length) ? all[j + 1] : null, attrs: tailAttrs, back: false },
2644
+ ];
2645
+ for (let k = 0; k < sides.length; k++) {
2646
+ const side = sides[k];
2647
+ if (!side.el || !side.attrs) continue;
2648
+ if (side.el.getAttribute('data-block-type') !== 'li') continue;
2649
+ // The neighbour's OWN outermost run — see the ⚠ above.
2650
+ const nrun = listRunOf(side.el);
2651
+ if (!nrun.length) continue;
2652
+ const head = nrun[0];
2653
+ if ((Number(head.getAttribute('data-indent')) || 0) !== side.attrs.indent) continue;
2654
+ const headType = head.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
2655
+ if (headType !== side.attrs.listType) continue;
2656
+ // Every line between two adjacent blocks is blank by construction
2657
+ // (buildBlockMap() strips a token's trailing newlines, so no block ever
2658
+ // owns a separator). Eating ALL of them is also 「正規化連續空行」:
2659
+ // measured, '- a\n\n\n- b\n' is still ONE loose list, so stopping after
2660
+ // one blank would leave the degrade in place.
2661
+ let moved = false;
2662
+ if (side.back) {
2663
+ while (startLine >= 2 && String(lines[startLine - 2]).trim() === '') {
2664
+ startLine -= 1; moved = true;
2665
+ }
2666
+ } else {
2667
+ while (endLine < lines.length && String(lines[endLine]).trim() === '') {
2668
+ endLine += 1; moved = true;
2669
+ }
2670
+ }
2671
+ // No separator between us and it: they are already two runs that
2672
+ // markdown keeps apart for a reason this rule does not touch (a
2673
+ // delimiter change, `- a` / `* b`). Nothing to eat, nothing to gate.
2674
+ if (!moved) continue;
2675
+ // Consequence 2. `columnOnly` is the honest option here: this run's
2676
+ // bytes are not being rewritten AT ALL (it sits entirely outside the
2677
+ // commit range), so the only question worth asking of it is the
2678
+ // `unsupported` one — and a hard-wrapped bystander li in there must not
2679
+ // veto the merge, exactly as §4.1 keeps it legal everywhere else.
2680
+ if (!listRunSupportsStructuralEdit(nrun, null, { columnOnly: true })) {
2681
+ return { startLine: range.startLine, endLine: range.endLine, ok: false };
2682
+ }
2683
+ }
2684
+ return { startLine: startLine, endLine: endLine, ok: true };
2685
+ }
2686
+
2687
+ // The {listType, indent} a span member will carry once `liEl` has become
2688
+ // `attrs`. Everything except the converted item keeps what it already has.
2689
+ function postConvertLiAttrs(el, liEl, attrs) {
2690
+ return {
2691
+ listType: el === liEl
2692
+ ? attrs.listType
2693
+ : (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
2694
+ indent: Number(el.getAttribute('data-indent')) || 0,
2695
+ };
2696
+ }
2697
+
2698
+ async function convertListItemType(liEl, run, target) {
2699
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2700
+ if (!range) return;
2701
+ const attrs = convertMd.listAttrsFor(target);
2702
+ if (!attrs) return;
2703
+ // §4.3 rule 2, in its revised form: the ruling keys on 「轉換結果是 li」,
2704
+ // so it applies HERE too, not only to the 非清單 → 清單 path below. This
2705
+ // is the S2 Task 3 defect — '- a' + blank + '1. b', both tight, and one
2706
+ // 轉換成 › 項目符號列表 on `b` merged them into one LOOSE list that froze
2707
+ // read-only with no banner. See widenRangeForListMerge()'s own note.
2708
+ //
2709
+ // The head/tail of the span are compared, not the converted item: a
2710
+ // conversion in the MIDDLE of a run leaves the run's outer lines alone, and
2711
+ // it is those that abut the separators. `tailEl` is the last member at the
2712
+ // span's TOP-LEVEL indent — listRunOf() includes descendants, and a nested
2713
+ // trailing item is not what the following list token would merge with.
2714
+ // Computed BEFORE mutateListRun() so a refusal has nothing to undo.
2715
+ const headEl = run[0];
2716
+ const headIndent = Number(headEl.getAttribute('data-indent')) || 0;
2717
+ let tailEl = headEl;
2718
+ run.forEach((el) => {
2719
+ if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
2720
+ });
2721
+ const merged = widenRangeForListMerge(range, run,
2722
+ postConvertLiAttrs(headEl, liEl, attrs), postConvertLiAttrs(tailEl, liEl, attrs));
2723
+ if (!merged.ok) { refuseStructuralListEdit(); return; }
2724
+ range.startLine = merged.startLine;
2725
+ range.endLine = merged.endLine;
2726
+ mutateListRun(() => {
2727
+ liEl.setAttribute('data-list-type', attrs.listType);
2728
+ liEl.setAttribute('data-task', attrs.task ? '1' : '0');
2729
+ const box = liCheckEl(liEl);
2730
+ if (attrs.task) {
2731
+ // Insert BEFORE the surface: §4.1 fixes the child order as
2732
+ // marker → check → text, and list-md.js's firstChildWithClass() plus
2733
+ // the delegated checkbox-toggle listener both assume it.
2734
+ if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
2735
+ } else if (box) {
2736
+ box.remove();
2737
+ }
2738
+ });
2739
+ // NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
2740
+ // sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
2741
+ // EXCLUDES `mutatedEl` from the replay map, so naming the converted item
2742
+ // is what sends ITS content back through inline-md.js's escapeText().
2743
+ // Measured: serializeInline('~5px') === '\~5px', and the runtime scenario
2744
+ // 'a list-type change never re-escapes the item’s own content' failed
2745
+ // exactly that way before this line lost its second argument. Nothing
2746
+ // here rewrote the item's CONTENT — only two attributes and a checkbox
2747
+ // span, none of which the serializer reads from `.ed-li-text` — so its
2748
+ // bytes belong to the file, same as every other member of the run.
2749
+ // Carrying it is free: list-md.js emits `head + carriedSplit.content` for
2750
+ // a carried line, i.e. it re-states the marker from the NEW attributes,
2751
+ // and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
2752
+ // carried source. That is what makes '- [x] alpha' → '- alpha' work.
2753
+ await commitListStructure(run, null, false,
2754
+ { presetRange: range, carryOver: bystanderCarryOver(run) });
2755
+ }
2756
+
2757
+ // The `data-block-type` a conversion target will carry once it is committed.
2758
+ // Only ever handed to indent-clamp's `operatedBecomes`, whose one question
2759
+ // is "is this still a li?" — but naming the real type keeps the call honest
2760
+ // if the pure function ever grows a second question.
2761
+ function convertedBlockType(target) {
2762
+ if (/^h[1-6]$/.test(target)) return 'heading';
2763
+ if (target === 'quote') return 'blockquote';
2764
+ if (target === 'code') return 'code';
2765
+ return 'paragraph';
2766
+ }
2767
+
2768
+ // S2 Task 4 — 轉換成 › 文字 / 標題 N / 程式碼 / 引用 on a li (§4.3 rule 1).
2769
+ //
2770
+ // The item LEAVES the run, so the run's own line range is rebuilt in three
2771
+ // pieces: the survivors before it, the converted block's own lines (read
2772
+ // from `lines`, never re-serialized — that is what keeps a `~5px` a `~5px`),
2773
+ // and the survivors after it. §3.8's renumbering falls out of re-serializing
2774
+ // each surviving half on its own; §4.3 rule 1's blank lines are the '\n\n'
2775
+ // joins between the pieces.
2776
+ //
2777
+ // `run` and the §4.3 run-wide gate are the CALLER's (convertBlockViaMenu),
2778
+ // for the reason spelled out there: the gate must sit ahead of every other
2779
+ // refusal so a multi-line li reports §4.1's banner and not stripMarker()'s
2780
+ // narrower one.
2781
+ //
2782
+ // Deliberately NOT re-checked here: `serializeBlocks().unsupported` on the
2783
+ // two halves. The gate above already serialized the WHOLE run through
2784
+ // listRunSupportsStructuralEdit(), and `unsupported` is a per-block fact, so
2785
+ // splitting the span cannot add a name. A naive `unsupported.length > 0`
2786
+ // re-check is worse than redundant — it refuses a HARD-WRAPPED bystander,
2787
+ // which §4.1 explicitly keeps legal (MULTILINE is filtered out of the
2788
+ // run-wide veto and re-checked against the TARGET's line range only).
2789
+ // Measured; the 'a multi-line bystander is replayed, not refused' scenario
2790
+ // is what notices.
2791
+ async function convertListItemAway(liEl, run, rec, target) {
2792
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2793
+ if (!range) return;
2794
+ const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
2795
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2796
+ const convertedLines = convertMd.emitAs(stripped.content, target, {});
2797
+
2798
+ const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2799
+ const idx = run.indexOf(liEl);
2800
+ const before = run.slice(0, idx);
2801
+ const after = run.slice(idx + 1);
2802
+
2803
+ // §3.4, and the FIRST production caller of the pure clamp's
2804
+ // `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
2805
+ // option is what tells clampIndents() that it can no longer anchor
2806
+ // anything, and rule 2's scope is measured from its position — and it is
2807
+ // NOT removed from the DOM: nothing below serializes it (its bytes come
2808
+ // from convert-md.js), commitRangeEdit() + safeRerenderAll() rebuild the
2809
+ // whole document from markdown anyway, and leaving it there means a
2810
+ // FAILED render shows the pre-conversion item rather than a hole.
2811
+ // mutateListRun() is still the wrapper, for its finally: `data-indent`
2812
+ // just moved on the survivors, so `data-run-start` (the ordered counter's
2813
+ // CSS reset) is stale for the length of the render round trip.
2814
+ //
2815
+ // ⚠ MEASURED, and it contradicts the plan's step 7: on the plan's own
2816
+ // '- alpha / (2sp)- child / (4sp)- grandchild' fixture this clamp is a
2817
+ // NO-OP on the emitted bytes. serializeBlocks() rebuilds its marker-width
2818
+ // stack from EMPTY for each span it is given (list-md.js:502,
2819
+ // `widths.slice(0, indent)`), so the first block of the `after` half
2820
+ // always emits at column 0 whatever its data-indent says — which is
2821
+ // exactly what the clamp would have done to it. The clamp earns its place
2822
+ // one shape further out: when the scope holds TWO segments (§3.4 rule 3),
2823
+ // their deltas differ and the width stack cannot derive that on its own.
2824
+ // The 'the §3.4 segment deltas survive the split commit' scenario is that
2825
+ // shape, and it is the one that goes red without this option.
2826
+ mutateListRun(() => {
2827
+ applyIndentClamp(run, liEl, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
2828
+ });
2829
+
2830
+ // No `mutatedEl`: the converted block is in neither half, and every
2831
+ // survivor's bytes are exactly what the file already holds. Naming a block
2832
+ // here EXCLUDES it from the replay map, which is what sends its content
2833
+ // back through escapeText() — see convertListItemType()'s note.
2834
+ const carry = bystanderCarryOver(before.concat(after));
2835
+ const pieces = [];
2836
+ if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
2837
+ pieces.push(convertedLines.join('\n'));
2838
+ if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
2839
+ let md = pieces.join('\n\n');
2840
+
2841
+ // §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
2842
+ // above already separate the pieces; outside it, the neighbouring line
2843
+ // belongs to another block and may be a li of an ADJACENT run (a
2844
+ // list-type change splits a run without a blank line — measured in Task
2845
+ // 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
2846
+ // added only when the neighbour is not already blank, which is also what
2847
+ // 「正規化連續空行」 amounts to here: no double separator is ever created.
2848
+ if (!before.length && range.startLine > 1 &&
2849
+ String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
2850
+ if (!after.length && range.endLine < lines.length &&
2851
+ String(lines[range.endLine]).trim() !== '') md = md + '\n';
2852
+
2853
+ const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
2854
+ if (result.op === null) return;
2855
+ const prevLines = lines;
2856
+ lines = result.lines;
2857
+ if (!(await safeRerenderAll())) {
2858
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2859
+ }
2860
+ }
2861
+
2862
+ // S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
2863
+ // NOT a list item (§4.3 rule 2, the return leg of rule 1).
2864
+ //
2865
+ // Line-level like every other conversion: the source comes from `lines`, so
2866
+ // the content is never re-serialized and a `~5px` stays a `~5px`. The block
2867
+ // owns its own lines and nothing else is re-emitted, so there is no run to
2868
+ // serialize and no carryOver to build — the two neighbouring runs are
2869
+ // deliberately left byte-untouched, and the ONLY thing that leaves the
2870
+ // block's own range is the blank separator rule 2 eats.
2871
+ //
2872
+ // No §4.1 run-wide gate on the way in: the source is not a li, so it belongs
2873
+ // to no run. The gate that DOES apply is the one inside
2874
+ // widenRangeForListMerge(), on whichever neighbouring run this block is
2875
+ // about to merge into.
2876
+ async function convertBlockIntoList(blockEl, rec, kind, target) {
2877
+ const attrs = convertMd.listAttrsFor(target);
2878
+ if (!attrs) return;
2879
+ const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), kind);
2880
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2881
+ const newLines = convertMd.emitAs(stripped.content, target, {});
2882
+
2883
+ // emitAs() puts a list target at column 0 with no indent prefix, so the
2884
+ // block's post-conversion identity is (target list type, indent 0) on both
2885
+ // edges — it emits one item, however many physical lines that item spans.
2886
+ const self = { listType: attrs.listType, indent: 0 };
2887
+ const merged = widenRangeForListMerge(
2888
+ { startLine: rec.startLine, endLine: rec.endLine }, [blockEl], self, self);
2889
+ if (!merged.ok) { refuseStructuralListEdit(); return; }
2890
+
2891
+ const result = commitRangeEdit({ lines, blocks, stack },
2892
+ merged.startLine, merged.endLine, newLines.join('\n'));
2893
+ if (result.op === null) return;
2894
+ const prevLines = lines;
2895
+ lines = result.lines;
2896
+ if (!(await safeRerenderAll())) {
2897
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1450
2898
  }
1451
2899
  }
1452
2900
 
2901
+ // The `.ed-li-check` chrome for a li that has just BECOME a task item.
2902
+ //
2903
+ // ⚠ This markup must stay byte-identical to the renderer's, which builds the
2904
+ // same span from a template literal at lib/md2doc.js:287-289:
2905
+ // <span class="ed-li-check" data-checked="0" role="checkbox"
2906
+ // aria-checked="false"></span>
2907
+ // Attribute ORDER matters as well as content: `bystanderCarryOver()` and the
2908
+ // burst baseline both compare innerHTML strings, and the very next successful
2909
+ // render replaces this element with the renderer's own — so a mismatch would
2910
+ // show up as a spurious diff for exactly one commit round trip. A new
2911
+ // task item is always unchecked (nothing in a `- alpha` line says otherwise).
2912
+ //
2913
+ // It deliberately carries NO click handler: the toggle is a delegated
2914
+ // listener on `.content` that resolves via closest('.ed-li-check').
2915
+ // It adds no NEW element type either — 'ed-li-check' is already in
2916
+ // list-md.js's closed LI_CHROME allowlist, so serializeBlocks() keeps
2917
+ // skipping it instead of reporting SPAN as unsupported.
2918
+ function buildLiCheckbox() {
2919
+ const box = document.createElement('span');
2920
+ box.className = 'ed-li-check';
2921
+ box.setAttribute('data-checked', '0');
2922
+ box.setAttribute('role', 'checkbox');
2923
+ box.setAttribute('aria-checked', 'false');
2924
+ return box;
2925
+ }
2926
+
1453
2927
  // The ⠿ menu's "MD 原始碼" escape hatch: discards (never commits) any
1454
2928
  // in-progress burst on THIS block — same "throw away my WYSIWYG edits,
1455
2929
  // switch to raw-edit against the untouched on-disk source" contract the
@@ -1631,17 +3105,49 @@
1631
3105
  }
1632
3106
  burst.history.flushTyping();
1633
3107
  // Task 7 (Phase 4): li burst — serialize the whole list run through
1634
- // serializeList(), commit via commitRangeEdit() over the full run range.
3108
+ // serializeBlocks(), commit via commitRangeEdit() over the full run range.
1635
3109
  // Per-li degrade (spec §8): if OTHER lis in the run are unsupported,
1636
3110
  // commit only the edited li's own line range to avoid lossy round-trip
1637
- // of their content (serializeList strips unsupported inline elements from
3111
+ // of their content (serializeBlocks strips unsupported inline elements from
1638
3112
  // `md`, so whole-run commit would silently delete their content).
1639
3113
  if (burst.blockType === 'li') {
1640
- const root = listRunRootOf(burst.editEl);
1641
- if (!root) { endBurstWithoutResolve(); return true; }
1642
- const { md: runMd, unsupported, unsupportedByLi } = listMd.serializeList(root);
3114
+ const editedLiEl = closestLiBlock(burst.editEl);
3115
+ const runEls = listRunOf(editedLiEl);
3116
+ if (!runEls.length) { endBurstWithoutResolve(); return true; }
3117
+ // T7 fix round 1 (HIGH-1): the §3.4 bystander replay belongs here too,
3118
+ // and this was the ONE list commit path that never got it — which made
3119
+ // "a line the user did not touch is never rewritten" false for the
3120
+ // commonest gesture of all, TYPING. The fully-supported branch below
3121
+ // commits `runMd` over the WHOLE run range, so every other item in the
3122
+ // run was re-serialized from the DOM on every keystroke burst:
3123
+ //
3124
+ // before: '- alpha one··\n alpha two ~t\n- bravo one··\n bravo two ~u\n- charlie ~v\n'
3125
+ // type one char into bravo, save
3126
+ // after: '- alpha one<br>alpha two \~t\n- bravo one<br>bravo two \~uZ\n- charlie \~v\n'
3127
+ //
3128
+ // — alpha's two source lines collapsed onto one bearing the literal
3129
+ // text '<br>' (the file lost a line) and charlie's '~v' was escaped,
3130
+ // in two items the user never opened.
3131
+ //
3132
+ // `mutatedEl` is the edited li: its DOM holds keystrokes `lines` has
3133
+ // not seen. bystanderCarryOver()'s dirty-burst exclusion already covers
3134
+ // it here (this is past the zero-edit guard, and `burst.blockId` IS this
3135
+ // li's id), so naming it is belt-and-braces — but it is the same fact
3136
+ // stated in the same place as the five structural call sites, which is
3137
+ // what stops the next reader having to re-derive it.
3138
+ //
3139
+ // The PARTIAL-run branch below is unaffected in OUTCOME, though not
3140
+ // untouched: a replayed bystander can emit a different number of lines
3141
+ // than a re-serialized one, so `first`/`last` shift. They index into
3142
+ // `runLines`, which is `runMd` from this very call, so the slice stays
3143
+ // self-consistent — and the bystanders' lines never reach `lines` on
3144
+ // that path anyway, because it commits only the edited block's own
3145
+ // source range.
3146
+ const carry = bystanderCarryOver(runEls, editedLiEl);
3147
+ const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
3148
+ listMd.serializeBlocks(runEls, { carryOver: carry });
1643
3149
  // Refuse if the EDITED li itself has unsupported inline content.
1644
- // RULING F-O: do NOT call openRawEditor() on a <li> element — injecting
3150
+ // RULING F-O: do NOT call openRawEditor() on a list block — injecting
1645
3151
  // a textarea into list structure corrupts list-md serialization and
1646
3152
  // renders badly. Show banner + teardown + rerenderAll (file is untouched,
1647
3153
  // burst never wrote to `lines`) + return false.
@@ -1652,7 +3158,7 @@
1652
3158
  await safeRerenderAll();
1653
3159
  return false;
1654
3160
  }
1655
- const range = runRangeOf({ lines, blocks, stack }, root);
3161
+ const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
1656
3162
  if (!range) { endBurstWithoutResolve(); return true; }
1657
3163
  let commitMd, commitStart, commitEnd;
1658
3164
  if (unsupported.length > 0) {
@@ -1671,25 +3177,42 @@
1671
3177
  // loose-'P', stray-TEXT and foreign-element cases in one shot.
1672
3178
  const editedBlock = blocks.find((b) => b.id === burst.blockId);
1673
3179
  if (!editedBlock) { endBurstWithoutResolve(); return true; }
1674
- // F-W (the trap): the slice offset MUST be the edited li's POSITION
1675
- // among the run's li blocks in DFS document order, NOT the source-line
1676
- // delta (editedBlock.startLine - range.startLine). The tight runMd
1677
- // emits exactly ONE line per li in DFS order (list-md.js pushes one
1678
- // line per item) and has NO blank lines, so a loose item present
1679
- // anywhere earlier in the run makes a later supported li's SOURCE
1680
- // startLine overshoot the tight runMd's line count — the old delta
1681
- // slice then returned '' and commitRangeRemoval DELETED the li's line.
1682
- // Indexing by li position is blank-line-robust: the k-th `.ed-block`
1683
- // li returned by querySelectorAll (pre-order DFS) is the k-th line of
1684
- // runMd (serializeList emits in the same pre-order DFS), so
1685
- // runLines[offset] is exactly THIS li's serialized line, independent
1686
- // of any loose blank lines in the source.
1687
- const runLiIds = Array.prototype.slice
1688
- .call(root.querySelectorAll('.ed-block'))
1689
- .map((el) => Number(el.getAttribute('data-block-id')));
1690
- const offset = runLiIds.indexOf(burst.blockId);
1691
- if (offset < 0) { endBurstWithoutResolve(); return true; }
1692
- commitMd = runMd.split('\n')[offset];
3180
+ // F-W (the trap): the slice MUST be located among the run's emitted
3181
+ // LINES, NOT by the source-line delta
3182
+ // (editedBlock.startLine - range.startLine). runMd has no blank lines,
3183
+ // so a loose item present anywhere earlier in the run makes a later
3184
+ // supported li's SOURCE startLine overshoot runMd's line count the
3185
+ // old delta slice then returned '' and commitRangeRemoval DELETED the
3186
+ // li's line.
3187
+ //
3188
+ // `lineMeta` is the serializer's own authoritative line -> blockId
3189
+ // mapping and is read instead of re-deriving anything from the DOM.
3190
+ // Two reasons position arithmetic cannot be used: a block the
3191
+ // serializer refuses emits NO line (controller note T2-C), and a
3192
+ // hard-wrapped block emits SEVERAL so a block maps to the index
3193
+ // RANGE of the entries bearing its id, and the commit replaces that
3194
+ // whole range. Taking only the first line here is what overwrote a
3195
+ // later item's source with a continuation line. lineMeta's blockId is
3196
+ // the raw getAttribute() string, hence the String() comparison (same
3197
+ // convention as the unsupportedByLi gate above).
3198
+ const runLines = runMd.split('\n');
3199
+ let first = -1;
3200
+ let last = -1;
3201
+ lineMeta.forEach((m, k) => {
3202
+ if (m.blockId !== editedIdStr) return;
3203
+ if (first < 0) first = k;
3204
+ last = k;
3205
+ });
3206
+ if (first < 0) { endBurstWithoutResolve(); return true; }
3207
+ // Round 5/6: the edited block's source line may also carry the markers
3208
+ // of zero-line ancestors (same-line nesting, '- - b'). They emit their
3209
+ // own lines in runMd but are not attributed to this block, so slicing
3210
+ // by id alone dropped them and the child lost its parent. A plain
3211
+ // ancestor is re-emitted on a line of its own; a TASK ancestor has to
3212
+ // stay on the child's line or its checkbox degrades to literal text.
3213
+ // See sharedMarkerPrefixFor().
3214
+ commitMd = sharedMarkerLinesBefore(lineMeta, first, editedBlock)
3215
+ .concat(runLines.slice(first, last + 1)).join('\n');
1693
3216
  commitStart = editedBlock.startLine;
1694
3217
  commitEnd = editedBlock.endLine;
1695
3218
  } else {
@@ -1709,26 +3232,19 @@
1709
3232
  lines = liCommitResult.lines;
1710
3233
  const liOk = await safeRerenderAll();
1711
3234
  if (!liOk) {
1712
- const liRollback = stack.undo(lines);
1713
- lines = liRollback ? liRollback.lines : liPrevLines;
3235
+ lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
1714
3236
  return false;
1715
3237
  }
1716
3238
  return true;
1717
3239
  }
1718
- // Task 4 (Phase 3): a list burst serializes through list-md.js's
1719
- // serializeList() (it takes the list ROOT element, exactly what
1720
- // burst.editEl already is for a 'list' burst — see armEditables() above)
1721
- // instead of inline-md.js's serializeInline(); Task 5: a table burst
1722
- // serializes through table-md.js's serializeTable() the same way (it
1723
- // takes the TABLE element, exactly what burst.editEl already is for a
3240
+ // Task 5: a table burst serializes through table-md.js's serializeTable()
3241
+ // (it takes the TABLE element, exactly what burst.editEl already is for a
1724
3242
  // 'table' burst). Every other block type (paragraph/heading) keeps using
1725
- // serializeInline() unchanged.
1726
- // LEGACY (pre-per-li): the 'list' branch below is unreachable in the
1727
- // per-li architecture — blockmap no longer emits type:'list' blocks, so
1728
- // no startBurst() call can produce blockType === 'list'. Kept until the
1729
- // whole 'list' surface is removed in a later cleanup task.
1730
- const result = burst.blockType === 'list' ? listMd.serializeList(burst.editEl)
1731
- : burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
3243
+ // inline-md.js's serializeInline() unchanged.
3244
+ // S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
3245
+ // emitted type:'list' blocks since Phase 4, so no startBurst() call could
3246
+ // produce blockType === 'list' and the branch was already dead code.
3247
+ const result = burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
1732
3248
  : inlineMd.serializeInline(burst.editEl);
1733
3249
  if (result.unsupported.length > 0) {
1734
3250
  // Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
@@ -1745,23 +3261,16 @@
1745
3261
  return false;
1746
3262
  }
1747
3263
  // Final-review Finding 5 (carried over): an emptied-out heading must not
1748
- // commit '#'.repeat(depth) + ' ' with nothing after the space. A list
1749
- // burst's `depth` is always null (blockDepthOf() only computes it for
1750
- // 'heading'), so it takes the plain result.md branch, same as a
1751
- // paragraph.
3264
+ // commit '#'.repeat(depth) + ' ' with nothing after the space. Every
3265
+ // non-heading burst's `depth` is null (blockDepthOf() only computes it for
3266
+ // 'heading'), so it takes the plain result.md branch.
1752
3267
  const newText = burst.depth === null ? result.md :
1753
3268
  (result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
1754
- // Task 4 fix (review, Important): a list burst that serialized to ''
1755
- // means every item was removed (each <li> always emits a non-empty
1756
- // marker linesee list-md.js so a 0-line result can ONLY happen
1757
- // with 0 <li>s left) delete the block's line range entirely instead
1758
- // of committing a single stray blank line. See commitListBlockRemoval()'s
1759
- // own comment for the exact byte-level contract.
1760
- // LEGACY (pre-per-li): the 'list' branch below is unreachable in the
1761
- // per-li architecture — kept until the whole 'list' surface is removed.
1762
- const commitResult = (burst.blockType === 'list' && result.md === '')
1763
- ? commitListBlockRemoval({ lines, blocks, stack }, burst.blockId)
1764
- : commitEdit({ lines, blocks, stack }, burst.blockId, newText);
3269
+ // S1 removed the pre-per-li "a whole-list burst that serialized to ''"
3270
+ // branch that lived here alongside the dead 'list' serializer arm above.
3271
+ // The per-li equivalent a run whose every item was removed is handled
3272
+ // by commitListStructure()'s own md === '' path.
3273
+ const commitResult = commitEdit({ lines, blocks, stack }, burst.blockId, newText);
1765
3274
  if (commitResult.op === null) {
1766
3275
  endBurstWithoutResolve();
1767
3276
  return true;
@@ -1770,8 +3279,7 @@
1770
3279
  lines = commitResult.lines;
1771
3280
  const ok = await safeRerenderAll();
1772
3281
  if (!ok) {
1773
- const rollback = stack.undo(lines);
1774
- lines = rollback ? rollback.lines : prevLines;
3282
+ lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
1775
3283
  // Burst stays open: DOM/history untouched, banner already shown by
1776
3284
  // safeRerenderAll(). rerenderAll() never ran its belt-and-braces
1777
3285
  // `currentBurst = null` reset on this failure path (that reset only
@@ -1800,6 +3308,49 @@
1800
3308
  return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
1801
3309
  }
1802
3310
 
3311
+ // ── T7: surviving a commit that renumbers every block id ───────────────
3312
+ // A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
3313
+ // that resolution can commit a DIFFERENT block's dirty editor, re-render,
3314
+ // and detach the element the gesture started from. Recovering by
3315
+ // `data-block-id` is not recovery at all: blockmap.js assigns ids 0..n-1 in
3316
+ // document order on EVERY render (`nextId = {v:0}`), so a commit that
3317
+ // changes the block COUNT shifts every later id and the captured id then
3318
+ // names the target's NEIGHBOUR. Measured: a fenced code block raw-edited
3319
+ // into two paragraphs changes the count WITHOUT changing the line count, so
3320
+ // '⠿ → 刪除' on the last paragraph deleted the one before it instead.
3321
+ //
3322
+ // Same defect class the S1 table fix closed (ensureTableBurstOpen()'s own
3323
+ // comment), and the same remedy: `startLine` is the stable handle, and the
3324
+ // block's own SOURCE LINES are the fingerprint proving the block sitting
3325
+ // there afterwards really is the same one. When the intervening commit moved
3326
+ // the target's own start line there is nothing left to resolve — the
3327
+ // fingerprint fails, the caller DROPS the gesture and says so. Never
3328
+ // guessed: completing every gesture is worth less than never acting on a
3329
+ // block the user did not point at.
3330
+ function blockSourceOf(block) {
3331
+ return lines.slice(block.startLine - 1, block.endLine).join('\n');
3332
+ }
3333
+ function captureBlockIdentity(blockEl) {
3334
+ if (!blockEl) return null;
3335
+ const raw = blockEl.getAttribute('data-block-id');
3336
+ if (raw === null) return null;
3337
+ const b = blocks.find((x) => x.id === Number(raw));
3338
+ if (!b) return null;
3339
+ return { startLine: b.startLine, type: b.type, source: blockSourceOf(b) };
3340
+ }
3341
+ function reresolveBlockEl(identity) {
3342
+ if (!identity) return null;
3343
+ const at = blocks.find((b) => b.startLine === identity.startLine);
3344
+ if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
3345
+ return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
3346
+ }
3347
+
3348
+ // What a caller says when it refuses to act rather than act on the wrong
3349
+ // block. Dismiss-only, same shape as refuseStructuralListEdit()'s banner —
3350
+ // the previous behaviour was to return silently, which reads to the user as
3351
+ // "the menu item is broken" and invites a second press.
3352
+ const DROPPED_GESTURE_MESSAGE = '文件已更新,請重試這個操作';
3353
+
1803
3354
  // Finds the block whose startLine === `startLine` in the current `blocks`
1804
3355
  // array and focuses its WYSIWYG surface. `caretToEnd` = true places the
1805
3356
  // caret after the last character; false (default) places it at the start.
@@ -1827,7 +3378,7 @@
1827
3378
  }
1828
3379
 
1829
3380
  // ── Task 8 (Phase 4): structural commit for a per-li block run ──────────
1830
- // Spec §3: a single <li> cannot emit its own line (ordinals and ancestor
3381
+ // Spec §3: a single list item cannot emit its own line (ordinals and ancestor
1831
3382
  // marker widths are tree-global), so the commit unit for ANY structural
1832
3383
  // change is the contiguous list RUN — re-serialize the whole run, replace
1833
3384
  // its line range once. That keeps every structural key at exactly ONE undo
@@ -1837,18 +3388,20 @@
1837
3388
  // handler — Task 9's delegated checkbox-toggle click handler calls this too.
1838
3389
  //
1839
3390
  // The DOM mutation must already have happened when this is called; it reads
1840
- // the live run back out through listMd.serializeList(). `focusStartLine` is
3391
+ // the live run back out through listMd.serializeBlocks(). `focusStartLine` is
1841
3392
  // the (post-commit) line the caret should end up on — see
1842
- // runLineOfListItem() below for how a caller computes it — or null to leave
3393
+ // runLineOfBlock() below for how a caller computes it — or null to leave
1843
3394
  // focus wherever the re-render puts it. Returns true on success, false when
1844
3395
  // the commit's own re-render failed (rolled back the same way every other
1845
3396
  // commit path in this file does: stack.undo() + restore `lines`).
1846
3397
  //
1847
- // `runEl` is normally the `.ed-li-text` surface the key came from, but any
1848
- // node still inside the run works (listRunRootOf() walks up from it, and the
1849
- // run ROOT itself resolves to itself). A caller whose mutation DETACHES that
1850
- // surface empty-Enter's li removal must pass the run root instead, or
1851
- // this cannot find the run at all.
3398
+ // S1: `runEls` is the POST-mutation run span itself (listRunOf() on any block
3399
+ // still in the run), not a node to walk up from the flat model has no list
3400
+ // container left to resolve. A caller whose mutation REMOVED the block the
3401
+ // key came from therefore computes the span from a surviving sibling, or
3402
+ // passes an empty array plus a `presetRange` when the run has no members
3403
+ // left; an empty span serializes to '' and takes the range-removal path
3404
+ // below, exactly as an emptied run did before.
1852
3405
  // Both "cannot locate the run" refusals below re-render before returning: the
1853
3406
  // caller's DOM mutation has ALREADY happened by the time this function runs,
1854
3407
  // so bailing out without a render would leave the screen showing a structural
@@ -1861,20 +3414,100 @@
1861
3414
  // re-serializes the WHOLE run — an unsupported li anywhere in it would have
1862
3415
  // its content silently deleted if the gate is skipped. The keydown handlers
1863
3416
  // (Tab, Enter) and the Task 9 checkbox click handler both enforce this.
1864
- async function commitListStructure(runEl, focusStartLine, caretToEnd, presetRange) {
1865
- const root = listRunRootOf(runEl);
1866
- if (!root) { endBurstWithoutResolve(); await safeRerenderAll(); return false; }
1867
- const { md } = listMd.serializeList(root);
3417
+ // Spec §3.4's bystander rule, resolved against the live file state: every
3418
+ // block in the commit span that the gesture did not itself touch, mapped to
3419
+ // the source lines it owns right now, so listMd.serializeBlocks() can replay
3420
+ // them instead of running them back through the (measurably lossy) inline
3421
+ // round trip. See its own carryOver comment for the measurement.
3422
+ //
3423
+ // ── T7: EVERY untouched block, keyed on its LINE RANGE ─────────────────
3424
+ // This used to name only the blocks the serializer reported in
3425
+ // `multiLineBlockIds`, and both halves of that were wrong.
3426
+ //
3427
+ // * WRONG SET. `multiLineBlockIds` answers "does the surface text hold a
3428
+ // '\n'", which is blind to a markdown HARD BREAK (two trailing spaces →
3429
+ // <br>, no newline in the DOM). Such an item was never carried, so a Tab
3430
+ // on a SIBLING re-serialised it: its two source lines collapsed onto one
3431
+ // line bearing the literal text '<br>', and the file lost a line in an
3432
+ // item nobody touched. list-md.js's detector has since been widened, but
3433
+ // the truth about how many lines a block owns lives HERE — in `blocks` —
3434
+ // not in a DOM heuristic, so that is what this reads.
3435
+ // * WRONG QUESTION. Even a genuinely single-line bystander must not be
3436
+ // re-serialised: escapeText() escapes a tilde marked never treats as
3437
+ // markup, so an untouched '~5px' came back '\~5px'. Carrying EVERY
3438
+ // untouched block makes "a line the user did not touch is never
3439
+ // rewritten" a property of the commit, rather than a special case for
3440
+ // hard-wrapped items. (Chosen over teaching inline-md.js not to escape a
3441
+ // lone '~': that changes a global serialisation rule and every other
3442
+ // caller with it, and it would still leave the next such character to
3443
+ // find. Controller note T7-B picked the same half.)
3444
+ //
3445
+ // The probe serialisation this used to run for the id list is gone with it,
3446
+ // which also takes one of the four serializeBlocks() passes a single Tab
3447
+ // used to make off the hot path.
3448
+ //
3449
+ // Three exclusions, all about "whose bytes are authoritative":
3450
+ //
3451
+ // * `mutatedEl` — the block the gesture REWROTE in the DOM before calling
3452
+ // the commit (Enter's split cuts its text in two; the empty-item outdent
3453
+ // clears its surface). Replaying its source would undo exactly that. A
3454
+ // column-only caller (Tab, the checkbox toggle) names nothing here, on
3455
+ // purpose: it changed an integer, not content, so its own target is a
3456
+ // bystander of itself and must come back byte-identical too.
3457
+ // * the block of an open burst whose surface has ACTUALLY been edited. Its
3458
+ // DOM holds keystrokes `lines` has not seen, and replaying `lines` would
3459
+ // silently throw them away. The dirty test is resolveBurst()'s own
3460
+ // zero-edit guard, so both places agree on what "edited" means — and an
3461
+ // UNEDITED burst is deliberately still replayed, because that is the
3462
+ // common case for Tab (click into an item, press Tab) and re-serializing
3463
+ // it would rewrite bytes the user only pressed an indent key on.
3464
+ // * a block with no resolvable, non-inverted range — a provisional split
3465
+ // item owns no source lines at all (no id yet), and a same-line nest's
3466
+ // outer item has endLine === startLine - 1. Neither has bytes to replay,
3467
+ // and slicing an inverted range would hand back the WRONG line. The
3468
+ // predicate is blockOwnsNoLine() itself rather than a second hand-typed
3469
+ // copy of `endLine < startLine`, so a change to that definition reaches
3470
+ // here too.
3471
+ function bystanderCarryOver(span, mutatedEl) {
3472
+ const dirtyId = (currentBurst && currentBurst.editEl &&
3473
+ burstBaselineHtml(currentBurst.editEl) !== currentBurst.original)
3474
+ ? String(currentBurst.blockId) : null;
3475
+ const out = {};
3476
+ let any = false;
3477
+ (span || []).forEach((el) => {
3478
+ if (!el || el === mutatedEl) return;
3479
+ const raw = el.getAttribute('data-block-id');
3480
+ if (raw === null || raw === dirtyId) return;
3481
+ const rec = blocks.find((b) => b.id === Number(raw));
3482
+ if (!rec || blockOwnsNoLine(el)) return;
3483
+ out[raw] = lines.slice(rec.startLine - 1, rec.endLine);
3484
+ any = true;
3485
+ });
3486
+ return any ? out : null;
3487
+ }
3488
+
3489
+ // `opts`: { presetRange, carryOver }. `carryOver` is the map
3490
+ // bystanderCarryOver() built — the CALLER builds it, once, right after its
3491
+ // own DOM mutation, and hands the SAME object to runLineOfBlock() as well:
3492
+ // the two must agree line-for-line (a replayed bystander can emit a
3493
+ // different number of lines than a re-serialized one would), and building it
3494
+ // twice also meant walking `blocks` twice per keystroke. Omitted (or null)
3495
+ // means "no bystander replay", which is only correct for a span whose blocks
3496
+ // all have their bytes in the DOM.
3497
+ async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
3498
+ const span = runEls || [];
3499
+ const presetRange = opts && opts.presetRange;
3500
+ const { md } = listMd.serializeBlocks(span, { carryOver: (opts && opts.carryOver) || null });
1868
3501
  // The run's line range is read back off its own li blocks' ids — which
1869
3502
  // requires at least one li to still BE there. A caller whose mutation
1870
3503
  // removed the run's last item therefore captures the range BEFORE mutating
1871
3504
  // and passes it in; everyone else lets it be derived here.
1872
- const range = presetRange || runRangeOf({ lines, blocks, stack }, root);
3505
+ const range = presetRange || runRangeOfBlocks({ lines, blocks, stack }, span);
1873
3506
  if (!range) { endBurstWithoutResolve(); await safeRerenderAll(); return false; }
1874
3507
  const result = (md === '')
1875
- // Every <li> emits a non-empty marker line, so md === '' can only mean
1876
- // the run has no items left — delete the range outright (absorbing one
1877
- // adjacent blank separator) instead of committing a stray blank line.
3508
+ // Every list block emits a non-empty marker line, so md === '' can only
3509
+ // mean the run has no items left — delete the range outright (absorbing
3510
+ // one adjacent blank separator) instead of committing a stray blank line.
1878
3511
  // Same contract commitListBlockRemoval() documents.
1879
3512
  ? commitRangeRemoval({ lines, blocks, stack }, range.startLine, range.endLine)
1880
3513
  : commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
@@ -1890,10 +3523,7 @@
1890
3523
  if (result.op !== null) lines = result.lines;
1891
3524
  const ok = await safeRerenderAll();
1892
3525
  if (!ok) {
1893
- if (result.op !== null) {
1894
- const rollback = stack.undo(lines);
1895
- lines = rollback ? rollback.lines : prevLines;
1896
- }
3526
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1897
3527
  // Deliberately NOT a second safeRerenderAll(), unlike the two refusals
1898
3528
  // above: this shape is different — a render WAS attempted and failed, so
1899
3529
  // rerenderAll() left `.content` untouched by contract and already showed
@@ -1909,30 +3539,47 @@
1909
3539
  return true;
1910
3540
  }
1911
3541
 
1912
- // The line `targetLi` will occupy once the run it belongs to is committed by
1913
- // commitListStructure() above. list-md.js's one-li==one-line write invariant
1914
- // means the run's serialized markdown has exactly one line per <li> in
1915
- // document order (its emission walk item line, then that item's nested
1916
- // lists, then the next sibling is a pre-order DFS, i.e. exactly the order
1917
- // querySelectorAll('li') returns), so the target's line is the run's own
1918
- // startLine plus its index in that walk. Holds even when the PRE-commit
1919
- // source had multi-line items, because the commit replaces the whole range
1920
- // with the canonical one-line-per-item form.
1921
- // Returns null when the run (or the item) cannot be located.
1922
- function runLineOfListItem(rootEl, targetLi) {
1923
- const range = runRangeOf({ lines, blocks, stack }, rootEl);
1924
- if (!range) return null;
1925
- const lis = Array.prototype.slice.call(rootEl.querySelectorAll('li'));
1926
- const idx = lis.indexOf(targetLi);
3542
+ // The line `targetBlock`'s own marker will occupy once the run span it belongs
3543
+ // to is committed by commitListStructure() above.
3544
+ //
3545
+ // Counted in EMITTED LINES, not in blocks: a block index is only the line
3546
+ // offset while every block emits exactly one line, and neither end of that
3547
+ // holds a refused block emits none (controller note T2-C) and a
3548
+ // hard-wrapped one emits several. `lineMeta` is the serializer's own
3549
+ // line -> blockId mapping, so the answer is the index of the FIRST entry
3550
+ // bearing this block's id (its marker line; continuation entries follow).
3551
+ // Returns null when the run (or the block) cannot be located.
3552
+ function runLineOfBlock(runEls, targetBlock, carryOver) {
3553
+ const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
3554
+ if (!range || !targetBlock) return null;
3555
+ const targetId = targetBlock.getAttribute('data-block-id');
3556
+ // The SAME carryOver commitListStructure() will use — passed in by the
3557
+ // caller rather than rebuilt here, because a rebuild is only *usually* the
3558
+ // same answer and this index has to be exactly it: a replayed bystander
3559
+ // can emit a different number of lines than a re-serialized one would (a
3560
+ // blank continuation is dropped on the re-serialize path but replayed
3561
+ // verbatim here), and this answer is an index INTO those lines.
3562
+ const { lineMeta } = listMd.serializeBlocks(runEls, { carryOver: carryOver || null });
3563
+ // A provisional block (split's new item) has no id yet, so it cannot be
3564
+ // found by one — fall back to counting the lines emitted before it.
3565
+ if (targetId === null) {
3566
+ const at = runEls.indexOf(targetBlock);
3567
+ if (at < 0) return null;
3568
+ const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
3569
+ let n = 0;
3570
+ lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
3571
+ return range.startLine + n;
3572
+ }
3573
+ const idx = lineMeta.findIndex((m) => m.blockId === targetId);
1927
3574
  if (idx === -1) return null;
1928
3575
  return range.startLine + idx;
1929
3576
  }
1930
3577
 
1931
3578
  // Degrade-never-lose gate for structural keys: refuse the key outright when
1932
- // ANY li in the run is unsupported (loose <p>-wrapped item, foreign element,
1933
- // stray text directly under the UL/OL, unsupported inline markup).
1934
- // serializeList() strips what it cannot represent from `md`, so committing
1935
- // such a run deletes that content silently.
3579
+ // ANY block in the run span is unsupported (loose <p>-wrapped item, foreign
3580
+ // child element, stray text directly inside the block, unsupported inline
3581
+ // markup). serializeBlocks() strips what it cannot represent from `md`, so
3582
+ // committing such a run deletes that content silently.
1936
3583
  //
1937
3584
  // RULING F-R — why the gate is RUN-WIDE, and why that does not contradict
1938
3585
  // spec §8's per-li narrowing. §8 governs which li you may TYPE in: a text
@@ -1947,8 +3594,93 @@
1947
3594
  // answer, not an over-broad one.
1948
3595
  //
1949
3596
  // Called BEFORE any mutation, so a refusal costs nothing to undo.
1950
- function listRunSupportsStructuralEdit(rootEl) {
1951
- return !!rootEl && listMd.serializeList(rootEl).unsupported.length === 0;
3597
+ //
3598
+ // ── Task 6: MULTILINE is a TARGET-ONLY refusal ─────────────────────────
3599
+ // Everything above stays true for content the serializer cannot represent.
3600
+ // A hard-wrapped item is a different animal: it is ordinary, valid markdown
3601
+ // that the serializer represents perfectly well — it just owns several lines
3602
+ // instead of one. Feeding it into the run-wide rule was measured, on this
3603
+ // repo's own CHANGELOG.md, to refuse Tab on 100% of list items (80.6% of that
3604
+ // file's 72 items are hard-wrapped, so effectively every run holds one). S1's
3605
+ // headline gesture was therefore dead on any real document.
3606
+ //
3607
+ // Spec §4.1 splits the roles instead: hard-wrapped refuses as the operation
3608
+ // TARGET (its own line range is what a split / convert / delete would have to
3609
+ // rewrite, and no caller here knows how), and as a BYSTANDER it is carried
3610
+ // through untouched — see commitListStructure()'s carryOver below, which
3611
+ // replays its source bytes rather than re-serializing it.
3612
+ //
3613
+ // `targetEl` is the block the gesture acts on. It is NOT optional in spirit:
3614
+ // omitting it falls back to the old run-wide answer, which is the safe
3615
+ // direction but also the useless one, so every call site names its target.
3616
+ //
3617
+ // ── DEVIATION from spec §4.1, with the measurement that forced it ──────
3618
+ // §4.1 lists Tab among the operations a hard-wrapped item refuses AS THE
3619
+ // TARGET. Implemented literally, that does not deliver the acceptance
3620
+ // condition this task was given ("Tab must work on CHANGELOG.md"), and the
3621
+ // reason is arithmetic rather than opinion: measured on this repo's
3622
+ // CHANGELOG.md at v2.10.2, 58 of 72 list items are hard-wrapped and NOT ONE
3623
+ // of the 14 single-line items shares a run with one. Target-only refusal
3624
+ // therefore moves the number of items that accept Tab from 14/72 to 14/72 —
3625
+ // it changes nothing at all on that document, because there the
3626
+ // hard-wrapped item is never the bystander, it is the item you want to
3627
+ // indent.
3628
+ //
3629
+ // What the rest of §4.1's list has in common is that it REWRITES the item's
3630
+ // content or its line count: a split cuts the text in two, a conversion
3631
+ // re-authors it as a fence or a paragraph, a delete removes its lines, a
3632
+ // duplicate re-emits them. None of those has a defined answer for an item
3633
+ // whose content spans several source lines, which is what the refusal is
3634
+ // protecting.
3635
+ //
3636
+ // Tab and Shift+Tab are not in that family. They change one integer and
3637
+ // nothing else, and the resulting byte change is EXACTLY §3.4's colDelta —
3638
+ // "對其 [startLine, endLine] 每一行套用同一欄位差", the same mechanism the
3639
+ // spec already defines for a bystander, pointed at the target instead. So
3640
+ // `opts.columnOnly` lets the indent keys through, and every other structural
3641
+ // caller keeps §4.1's refusal untouched.
3642
+ //
3643
+ // `columnOnly` is a CRITERION, not the name of two keys (T7, and §4.1 has
3644
+ // been amended to match so the next such operation needs no fresh ruling):
3645
+ // an operation is column-only when it changes no content, no line count, and
3646
+ // nothing but leading columns or the characters inside a marker. The GFM
3647
+ // checkbox toggle qualifies on exactly the same arithmetic as Tab — '[ ] '
3648
+ // and '[x] ' are the same width, so its colDelta is 0 — and it was the
3649
+ // second caller to need it. Anything that rewrites the item's TEXT or its
3650
+ // LINE COUNT (split, convert, delete, duplicate) is not column-only and must
3651
+ // keep refusing a multi-line target.
3652
+ //
3653
+ // The one thing that must not happen is replaying stale bytes over live
3654
+ // keystrokes; bystanderCarryOver() below is what draws that line, by
3655
+ // excluding a burst whose surface has actually been edited.
3656
+ function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
3657
+ if (!runEls || !runEls.length) return false;
3658
+ const res = listMd.serializeBlocks(runEls);
3659
+ const multi = res.multiLineBlockIds || [];
3660
+ // Anything OTHER than MULTILINE still refuses run-wide, unchanged.
3661
+ for (let i = 0; i < res.unsupported.length; i++) {
3662
+ if (res.unsupported[i] !== 'MULTILINE') return false;
3663
+ }
3664
+ if (opts && opts.columnOnly) return true;
3665
+ if (!targetEl) return multi.length === 0;
3666
+ // T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
3667
+ // `multiLineBlockIds` reports a '\n' in the item's surface text, which
3668
+ // sees a LAZY continuation and is blind to a markdown HARD BREAK (two
3669
+ // trailing spaces -> <br>, no newline in the DOM). Enter on such an item
3670
+ // was therefore accepted, and re-serialised its two source lines into one
3671
+ // line bearing the literal text '<br>' — precisely the rewrite §4.1's
3672
+ // refusal exists to prevent. How many lines a block owns is a fact about
3673
+ // the FILE, so it is read off `blocks` here rather than guessed from the
3674
+ // DOM in list-md.js (which was tried: '<br>' also matches the placeholder
3675
+ // Chromium leaves when the last character is deleted, and an emptied item
3676
+ // must stay removable). `multi` is kept as well — it costs nothing and
3677
+ // covers any surface newline that is not a line-range fact.
3678
+ const targetRaw = targetEl.getAttribute('data-block-id');
3679
+ const targetRec = blocks.find((b) => b.id === Number(targetRaw));
3680
+ if (targetRec && targetRec.endLine > targetRec.startLine) return false;
3681
+ // getAttribute() strings on both sides — the same convention
3682
+ // unsupportedByLi[].blockId uses.
3683
+ return multi.indexOf(targetRaw) === -1;
1952
3684
  }
1953
3685
 
1954
3686
  // Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
@@ -2053,7 +3785,24 @@
2053
3785
  // wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
2054
3786
  // it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
2055
3787
  function handleBurstKeydown(e, editEl) {
2056
- if (!currentBurst || currentBurst.editEl !== editEl) return;
3788
+ if (!currentBurst || currentBurst.editEl !== editEl) {
3789
+ // v2.11.1 acceptance, escape class B. This bail is reachable with the
3790
+ // surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
3791
+ // `currentBurst` without blurring (Ctrl+S is the everyday way in), and
3792
+ // the delegated handler's call site below `return`s unconditionally, so
3793
+ // nothing else in the document handler runs either. For every other key
3794
+ // that is the right answer — the surface is a plain contenteditable and
3795
+ // the browser's default IS the behaviour we want. Tab is the one key
3796
+ // whose default is not "insert something" but "walk the caret out of the
3797
+ // document": measured on 2.11.0 it moved focus to that same item's own +
3798
+ // button (Shift+Tab, to the previous block's ⠿). Spec §3.5 names this
3799
+ // outright — 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點巡覽.
3800
+ // Swallowed, not acted on: there is no burst to act within, and an
3801
+ // indent from a resolved burst would be a structural edit the user did
3802
+ // not ask for.
3803
+ if (e.key === 'Tab') e.preventDefault();
3804
+ return;
3805
+ }
2057
3806
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
2058
3807
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
2059
3808
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -2062,13 +3811,31 @@
2062
3811
  if (currentBurst.blockType === 'li') {
2063
3812
  if (handleLiKeydown(e, editEl)) return;
2064
3813
  }
2065
- // Task 4 (Phase 3): a list burst's Enter/Tab/Shift+Tab semantics are
2066
- // materially different from paragraph/heading (split/indent/outdent
2067
- // instead of commit/br) handleListKeydown() owns that entire surface
2068
- // (including its own Escape/Ctrl+Z/Ctrl+Y, mirrored from below) and
2069
- // returns before any of the paragraph/heading branches run.
2070
- if (currentBurst.blockType === 'list') {
2071
- handleListKeydown(e, editEl);
3814
+ // Task 6 spec §3.5's other two rows. Tab is CONSUMED here: the
3815
+ // alternative is not "nothing happens", it is the browser's own focus
3816
+ // traversal walking the caret out of the document body, which is both a
3817
+ // surprise and (because it fires focusout) an unasked-for commit.
3818
+ //
3819
+ // heading — one level down / up, clamped to H1..H6 by
3820
+ // changeHeadingDepth(), which is the same source-level
3821
+ // transform the ⠿ menu's ± buttons already use.
3822
+ // paragraph — a true no-op. Not "unhandled": preventDefault() and
3823
+ // return, so the block is byte-identical afterwards.
3824
+ //
3825
+ // T7 correction: that is the WHOLE list, not a sample of it. This branch
3826
+ // runs only for a block with an open burst, and armEditables() opens one
3827
+ // for exactly four block types — paragraph, heading, li, table. `li` has
3828
+ // already returned above (handleLiKeydown()), and a table cell never
3829
+ // reaches here at all (it runs through handleTableCellKeydown(), whose Tab
3830
+ // keeps its cell-navigation contract). Blockquote and fenced code are
3831
+ // never armed — they are degraded blocks whose click opens the raw
3832
+ // textarea — so no "consumed no-op" branch has ever executed for them,
3833
+ // whatever the commit message that introduced this said.
3834
+ if (e.key === 'Tab') {
3835
+ e.preventDefault();
3836
+ if (currentBurst.blockType === 'heading') {
3837
+ changeHeadingDepth(currentBurst.blockEl, e.shiftKey ? -1 : 1);
3838
+ }
2072
3839
  return;
2073
3840
  }
2074
3841
  if (e.key === 'Enter') {
@@ -2109,136 +3876,93 @@
2109
3876
  // by history.snap() per the Global Constraint ("every structural mutation
2110
3877
  // -> history snap").
2111
3878
 
2112
- // Nearest ancestor <li> of `node` (inclusive), never crossing `root` —
2113
- // same walk-up pattern as closestMarkAncestor() above, specialized to LI.
2114
- function closestListItem(node, root) {
2115
- let n = node;
2116
- while (n && n !== root) {
2117
- if (n.nodeType === 1 && n.nodeName === 'LI') return n;
2118
- n = n.parentNode;
2119
- }
2120
- return null;
2121
- }
2122
-
2123
- function caretListItem(root) {
3879
+ // S1: the caret's own li block, or null. closestLiBlock() replaces the old
3880
+ // "nearest ancestor <li>, never crossing root" walk a flat block has no
3881
+ // list ancestor to cross, and the block boundary is the natural stop.
3882
+ function caretLiBlock() {
2124
3883
  const sel = window.getSelection();
2125
3884
  if (!sel.rangeCount) return null;
2126
- return closestListItem(sel.getRangeAt(0).startContainer, root);
3885
+ return closestLiBlock(sel.getRangeAt(0).startContainer);
2127
3886
  }
2128
3887
 
2129
3888
  // Task 4 fix (review, Critical): a NON-collapsed selection whose two
2130
- // boundary points resolve to DIFFERENT <li> elements (or either resolves
3889
+ // boundary points resolve to DIFFERENT list blocks (or either resolves
2131
3890
  // to none) has no defined split semantics under the brief's caret-based
2132
3891
  // Enter contract — splitListItemAtCaret()'s Range extractContents() was
2133
- // anchored only to the START container's own <li>, so a cross-item
3892
+ // anchored only to the START container's own item, so a cross-item
2134
3893
  // selection silently deleted whatever the selection covered in the OTHER
2135
3894
  // item(s) before the (wrong) split ran. True only for a genuinely
2136
3895
  // cross-item selection; a same-item multi-character selection is still a
2137
3896
  // normal (delete-then-split) Enter, handled by splitListItemAtCaret()
2138
3897
  // itself.
2139
- function selectionSpansMultipleListItems(root) {
3898
+ function selectionSpansMultipleListItems() {
2140
3899
  const sel = window.getSelection();
2141
3900
  if (!sel.rangeCount) return false;
2142
3901
  const range = sel.getRangeAt(0);
2143
3902
  if (range.collapsed) return false;
2144
- const startLi = closestListItem(range.startContainer, root);
2145
- const endLi = closestListItem(range.endContainer, root);
3903
+ const startLi = closestLiBlock(range.startContainer);
3904
+ const endLi = closestLiBlock(range.endContainer);
2146
3905
  return !startLi || !endLi || startLi !== endLi;
2147
3906
  }
2148
3907
 
2149
- // Any UL/OL that is a direct child of `li` — per list-md.js's documented
2150
- // DOM shape, a nested sublist (if any) is always exactly one such
2151
- // trailing child; scanning ALL children (not just the last) is defensive
2152
- // against an edit having transiently left it somewhere else.
2153
- function directNestedListOf(li) {
2154
- for (let i = 0; i < li.childNodes.length; i++) {
2155
- const c = li.childNodes[i];
2156
- if (c.nodeType === 1 && (c.nodeName === 'UL' || c.nodeName === 'OL')) return c;
2157
- }
2158
- return null;
2159
- }
2160
-
2161
- // Task 8: `li`'s own nested list whose tag is exactly `nodeName` ('UL'/'OL'),
2162
- // or null. outdentListItem() below needs the TYPE-MATCHED sublist, not merely
2163
- // the first one: appending adopted items into a sublist of the other type
2164
- // silently rewrites their markers (a bullet adopted into an <ol> comes back
2165
- // as '1.'). Emitting a second sublist of the other type instead is fine —
2166
- // list-md.js's serializeListNode() iterates every nested list of an item.
2167
- function directNestedListOfType(li, nodeName) {
2168
- for (let i = 0; i < li.childNodes.length; i++) {
2169
- const c = li.childNodes[i];
2170
- if (c.nodeType === 1 && c.nodeName === nodeName) return c;
2171
- }
2172
- return null;
3908
+ // S1 replacement for directNestedListOf(): "does this item own children?" is
3909
+ // now "is the NEXT block an li at a strictly greater indent?". A block's
3910
+ // children are, by construction of the flat renderer's DFS walk, the
3911
+ // contiguous run of deeper blocks immediately following it.
3912
+ // RULING F-Q's guard reads this.
3913
+ function liBlockHasChildren(blockEl) {
3914
+ const self = liAttrs(blockEl);
3915
+ if (!self) return false;
3916
+ const all = allBlockEls();
3917
+ const i = all.indexOf(blockEl);
3918
+ if (i < 0) return false;
3919
+ const next = liAttrs(all[i + 1]);
3920
+ return !!next && next.indent > self.indent;
2173
3921
  }
2174
3922
 
2175
3923
  // Task 8: the per-li edit surface (`<div class="ed-li-text">`, see
2176
- // lib/md2doc.js's renderEditModeList) that holds `li`'s own inline content.
2177
- // Falls back to the <li> itself for the pre-per-li bare shape, so the
2178
- // structural helpers below keep working against either DOM.
2179
- function liTextEl(li) {
2180
- for (let i = 0; i < li.childNodes.length; i++) {
2181
- const c = li.childNodes[i];
3924
+ // lib/md2doc.js's renderEditModeList) that holds this block's own inline
3925
+ // content, or null. S1 removed the pre-per-li "fall back to the <li> itself"
3926
+ // shape: a flat li block ALWAYS has exactly one .ed-li-text child (the
3927
+ // renderer emits it unconditionally, and splitListItemAtCaret() below
3928
+ // reproduces it), so a null here means the element is not a list block at
3929
+ // all which callers must not paper over.
3930
+ function liTextEl(blockEl) {
3931
+ for (let i = 0; i < blockEl.childNodes.length; i++) {
3932
+ const c = blockEl.childNodes[i];
2182
3933
  if (c.nodeType === 1 && c.nodeName === 'DIV' &&
2183
3934
  c.classList && c.classList.contains('ed-li-text')) return c;
2184
3935
  }
2185
- return li;
3936
+ return null;
2186
3937
  }
2187
3938
 
2188
- // Task 8: the non-editable checkbox chrome (spec §6) of `li`, if any.
2189
- function liCheckEl(li) {
2190
- for (let i = 0; i < li.childNodes.length; i++) {
2191
- const c = li.childNodes[i];
3939
+ // Task 8: the non-editable checkbox chrome (spec §6) of `blockEl`, if any.
3940
+ function liCheckEl(blockEl) {
3941
+ for (let i = 0; i < blockEl.childNodes.length; i++) {
3942
+ const c = blockEl.childNodes[i];
2192
3943
  if (c.nodeType === 1 && c.nodeName === 'SPAN' &&
2193
3944
  c.classList && c.classList.contains('ed-li-check')) return c;
2194
3945
  }
2195
3946
  return null;
2196
3947
  }
2197
3948
 
2198
- // An item is "empty" (brief: "Enter on EMPTY item = remove it") when it
2199
- // has no nested sublist (removing it would orphan real content — refuse
2200
- // that case rather than silently dropping children) and its own text is
2201
- // blank (covers a bare placeholder <br> too — a <br>-only li's
2202
- // textContent is '').
2203
- // LEGACY (pre-per-li): used only by handleListKeydown()'s whole-list surface,
2204
- // which is itself already unreachable (blockmap emits no type:'list' blocks in
2205
- // the per-li architecture, so no burst can have blockType 'list' — see
2206
- // resolveBurst()'s own LEGACY note); kept because that surface's empty-Enter
2207
- // REMOVES the item, which is why the sublist refusal is still correct there.
2208
- // The per-li path uses liOwnTextIsBlank() below; see RULING F-Q on its own
2209
- // comment for why the two must differ.
2210
- function isEmptyListItem(li) {
2211
- if (directNestedListOf(li)) return false;
2212
- return li.textContent.replace(/ /g, ' ').trim() === '';
2213
- }
2214
-
2215
3949
  // Task 8 / RULING F-Q: "empty" for the PER-LI Enter contract (spec §11 row 3)
2216
- // means the item's OWN text is blank. A nested sublist does NOT disqualify it,
2217
- // unlike isEmptyListItem() above: that predicate guards a path which REMOVES
2218
- // the item, where refusing is the only way not to orphan its children, while
2219
- // row 3's press OUTDENTS the item and the subtree travels with it — so there
3950
+ // means the item's OWN text is blank. Owning children does NOT disqualify it:
3951
+ // row 3's press OUTDENTS the item and the subtree travels with it, so there
2220
3952
  // is nothing to orphan. Spec §4 / §11 row 3 state the outdent with no
2221
- // carve-out, so gating row 3 on isEmptyListItem() silently sent an empty
2222
- // item that owned a sublist to the row-1 SPLIT instead (two empty items, the
3953
+ // carve-out, so gating row 3 on "has no children" silently sent an empty item
3954
+ // that owned a sublist to the row-1 SPLIT instead (two empty items, the
2223
3955
  // subtree re-parented under the second).
2224
3956
  //
2225
- // "Own text" is the `.ed-li-text` surface's text, which by construction
2226
- // excludes the nested list (a sibling of that div inside the <li>). NBSP is
2227
- // normalised to a space so a surface holding only a non-breaking space still
2228
- // counts as blank, and a bare placeholder <br> counts too (its textContent is
2229
- // '') both carried over from isEmptyListItem().
2230
- function liOwnTextIsBlank(li) {
2231
- const textEl = liTextEl(li);
2232
- if (textEl !== li) return textEl.textContent.replace(/ /g, ' ').trim() === '';
2233
- // Bare (pre-per-li) shape: no wrapper div, so sum the item's own non-list
2234
- // children explicitly rather than reading li.textContent, which would
2235
- // include every descendant item's text.
2236
- let text = '';
2237
- for (let i = 0; i < li.childNodes.length; i++) {
2238
- const c = li.childNodes[i];
2239
- if (c.nodeName !== 'UL' && c.nodeName !== 'OL') text += c.textContent;
2240
- }
2241
- return text.replace(/ /g, ' ').trim() === '';
3957
+ // "Own text" is the `.ed-li-text` surface's text, which in the flat model is
3958
+ // the item's own content by construction descendants are separate blocks,
3959
+ // not descendants of this element. NBSP is normalised to a space so a surface
3960
+ // holding only a non-breaking space still counts as blank, and a bare
3961
+ // placeholder <br> counts too (its textContent is '').
3962
+ function liOwnTextIsBlank(blockEl) {
3963
+ const textEl = liTextEl(blockEl);
3964
+ if (!textEl) return false;
3965
+ return textEl.textContent.replace(/\u00a0/g, ' ').trim() === '';
2242
3966
  }
2243
3967
 
2244
3968
  // Task 8 / RULING F-U: true when `el` holds nothing any serializer would emit
@@ -2265,35 +3989,36 @@
2265
3989
  return true;
2266
3990
  }
2267
3991
 
2268
- // Splits `li` into two siblings at the caret via Range surgery — the same
2269
- // extractContents()-based pattern wrapRangeIn() above already uses, so
2270
- // inline formatting (a caret mid-<strong>, say) splits cleanly instead of
3992
+ // Splits `blockEl` into two sibling BLOCKS at the caret via Range surgery —
3993
+ // the same extractContents()-based pattern wrapRangeIn() above already uses,
3994
+ // so inline formatting (a caret mid-<strong>, say) splits cleanly instead of
2271
3995
  // being torn.
2272
3996
  //
2273
- // Task 8 (per-li arch): the caret lives inside `li`'s own
2274
- // `<div class="ed-li-text">` surface, not directly under the <li>, so the
2275
- // tail range runs to the END OF THAT DIV and the new sibling gets a
2276
- // .ed-li-text div of its own to hold it. The provisional <li> deliberately
2277
- // carries NO data-block-id / data-indent / data-list-type: list-md.js reads
2278
- // those only for per-li unsupported ATTRIBUTION, and the very next
2279
- // commitListStructure() + re-render replaces it with a real, server-numbered
2280
- // block anyway. A `.ed-li-check` sibling IS reproduced (unchecked) so that
2281
- // splitting a task item yields another task item rather than silently
2282
- // converting the tail half to a plain bullet.
3997
+ // The caret lives inside the block's own `<div class="ed-li-text">` surface,
3998
+ // so the tail range runs to the END OF THAT DIV and the new block gets a
3999
+ // .ed-li-text div of its own to hold it. The provisional block deliberately
4000
+ // carries NO data-block-id: list-md.js reads it only for per-li unsupported
4001
+ // ATTRIBUTION, and the very next commitListStructure() + re-render replaces
4002
+ // this element with a real, server-numbered block anyway. It DOES carry
4003
+ // data-block-type / data-list-type / data-task / data-indent, all of which
4004
+ // serializeBlocks() reads to emit the line, plus a `.ed-li-marker` and (for a
4005
+ // task item) an unchecked `.ed-li-check`, so splitting a task item yields
4006
+ // another task item rather than silently converting the tail half to a plain
4007
+ // bullet.
2283
4008
  //
2284
- // A trailing nested sublist travels with the NEW (second) item per
2285
- // list-md.js's documented shape it is always `li`'s last child, i.e. it
2286
- // physically follows the caret, so this is the same deterministic
2287
- // "whichever half it follows in DOM order" rule the pre-Task-8 version had,
2288
- // and it matches the spec's Enter contract (the new block inherits the
2289
- // subtree).
4009
+ // S1: the subtree needs no handling at all. A block's children are the
4010
+ // contiguous deeper blocks that FOLLOW it, and the new block is inserted
4011
+ // directly after the old one — so the subtree lands under the NEW item for
4012
+ // free, which is the same "whichever half it follows in DOM order" rule the
4013
+ // nested version had and what the spec's Enter contract requires.
2290
4014
  //
2291
- // Returns the new <li>, or null when the caret is not inside `li`'s own
2292
- // surface (nothing mutated).
2293
- function splitListItemAtCaret(li) {
4015
+ // Returns the new block element, or null when the caret is not inside
4016
+ // `blockEl`'s own surface (nothing mutated).
4017
+ function splitListItemAtCaret(blockEl) {
2294
4018
  const sel = window.getSelection();
2295
4019
  if (!sel.rangeCount) return null;
2296
- const textEl = liTextEl(li);
4020
+ const textEl = liTextEl(blockEl);
4021
+ if (!textEl) return null;
2297
4022
  const range = sel.getRangeAt(0).cloneRange();
2298
4023
  // Containment is checked BEFORE deleteContents() so the refusal below is a
2299
4024
  // true no-op rather than "the selection was deleted, then we gave up".
@@ -2303,220 +4028,141 @@
2303
4028
  tailRange.setStart(range.startContainer, range.startOffset);
2304
4029
  tailRange.setEnd(textEl, textEl.childNodes.length);
2305
4030
  const tailFrag = tailRange.extractContents();
2306
- const newLi = document.createElement('li');
2307
- const check = liCheckEl(li);
4031
+
4032
+ const newBlock = document.createElement('div');
4033
+ newBlock.className = 'ed-block';
4034
+ newBlock.setAttribute('data-block-type', 'li');
4035
+ newBlock.setAttribute('data-list-type', blockEl.getAttribute('data-list-type') || 'ul');
4036
+ newBlock.setAttribute('data-task', blockEl.getAttribute('data-task') === '1' ? '1' : '0');
4037
+ setBlockIndent(newBlock, Number(blockEl.getAttribute('data-indent')) || 0);
4038
+ const marker = document.createElement('span');
4039
+ marker.className = 'ed-li-marker';
4040
+ marker.setAttribute('aria-hidden', 'true');
4041
+ newBlock.appendChild(marker);
4042
+ const check = liCheckEl(blockEl);
2308
4043
  if (check) {
2309
4044
  const newCheck = check.cloneNode(false);
2310
4045
  newCheck.setAttribute('data-checked', '0');
2311
4046
  newCheck.setAttribute('aria-checked', 'false');
2312
- newLi.appendChild(newCheck);
2313
- }
2314
- if (textEl === li) {
2315
- // Pre-per-li bare shape: no .ed-li-text wrapper to reproduce.
2316
- newLi.appendChild(tailFrag);
2317
- } else {
2318
- const newText = document.createElement('div');
2319
- newText.className = 'ed-li-text';
2320
- newText.appendChild(tailFrag);
2321
- newLi.appendChild(newText);
2322
- }
2323
- const sub = directNestedListOf(li);
2324
- if (sub) newLi.appendChild(sub);
2325
- li.parentNode.insertBefore(newLi, li.nextSibling);
2326
- return newLi;
2327
- }
2328
-
2329
- // Removes `li` from its list. If that empties out a NESTED sublist (never
2330
- // the burst's own root list — editEl's own parent is the block <div>, not
2331
- // an <li>, so this never touches the root), the now-empty <ul>/<ol> is
2332
- // cleaned up too rather than left dangling.
2333
- function removeListItem(li) {
2334
- const parentList = li.parentNode;
2335
- parentList.removeChild(li);
2336
- if (parentList.childElementCount === 0 &&
2337
- parentList.parentNode && parentList.parentNode.nodeName === 'LI') {
2338
- parentList.parentNode.removeChild(parentList);
2339
- }
2340
- }
2341
-
2342
- // Tab: `li` becomes the LAST child of its previous sibling's own nested sublist
2343
- // of the SAME ordered/unordered type as the list `li` is moving out of
2344
- // (creating one when `prev` has no type-matched sublist). No previous sibling
2345
- // -> no-op (brief). Returns true iff a mutation actually happened, so the
2346
- // caller only snaps history on a real change.
4047
+ newBlock.appendChild(newCheck);
4048
+ }
4049
+ const newText = document.createElement('div');
4050
+ newText.className = 'ed-li-text';
4051
+ newText.appendChild(tailFrag);
4052
+ newBlock.appendChild(newText);
4053
+ blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
4054
+ return newBlock;
4055
+ }
4056
+
4057
+ // S1: removing an item is removing its element. There is no list container
4058
+ // left to clean up when it empties — the run simply has one member fewer.
4059
+ // Callers must have established that the block owns no children (see
4060
+ // liBlockHasChildren()); the flat model would otherwise leave orphans behind
4061
+ // at a deeper indent than anything above them.
4062
+ function removeListItem(blockEl) {
4063
+ blockEl.parentNode.removeChild(blockEl);
4064
+ }
4065
+
4066
+ // The contiguous run of blocks immediately after `blockEl` whose indent is
4067
+ // strictly greater than `indent` i.e. that item's subtree in the flat
4068
+ // model. Used by the outdent below, which moves the subtree with its owner.
4069
+ function subtreeBlocksAfter(blockEl, indent) {
4070
+ const all = allBlockEls();
4071
+ const i = all.indexOf(blockEl);
4072
+ const out = [];
4073
+ if (i < 0) return out;
4074
+ for (let k = i + 1; k < all.length; k++) {
4075
+ const a = liAttrs(all[k]);
4076
+ if (!a || a.indent <= indent) break;
4077
+ out.push(all[k]);
4078
+ }
4079
+ return out;
4080
+ }
4081
+
4082
+ // Tab (spec §3.5, 清單項 row): the item's indent goes up by one, clamped by
4083
+ // spec §3.4 rule 1 — "the previous block's indent + 1", with an upper bound of
4084
+ // 0 when the previous block is not a list item. Returns true iff something
4085
+ // actually moved, so the caller only commits on a real change.
2347
4086
  //
2348
- // RULING F-T: the target must be type-matched, and the type that matters is
2349
- // the MOVING item's own current list not whichever sublist `prev` happens to
2350
- // own first. `directNestedListOf(prev)` returned that first sublist regardless
2351
- // of its tag, so Tab on a bullet whose previous sibling owned an <ol> appended
2352
- // the bullet into that <ol>, and list-md.js derives an item's marker from its
2353
- // list node (serializeListNode()'s `ordered`) — silently re-emitting an item
2354
- // the user never touched as '1.'/'2.'. Identical root cause to
2355
- // outdentListItem()'s adoption target below; see directNestedListOfType().
2356
- function indentListItem(li) {
2357
- const prev = li.previousElementSibling;
2358
- if (!prev || prev.nodeName !== 'LI') return false;
2359
- const listTag = li.parentNode.nodeName; // captured before the move detaches li
2360
- let nested = directNestedListOfType(prev, listTag);
2361
- if (!nested) {
2362
- nested = document.createElement(listTag === 'OL' ? 'ol' : 'ul');
2363
- prev.appendChild(nested);
2364
- }
2365
- li.parentNode.removeChild(li);
2366
- nested.appendChild(li);
2367
- return true;
2368
- }
2369
-
2370
- // Shift+Tab (spec §11 row 6, user-verified against Notion): `li` moves out
2371
- // to become the NEXT sibling of the <li> that owns its current list, and its
2372
- // former FOLLOWING same-level siblings are ADOPTED as its children. Top
2373
- // level (no owning <li>) -> no-op (row 8).
4087
+ // ── Task 6: THE SUBTREE NO LONGER FOLLOWS ──────────────────────────────
4088
+ // Up to v2.10.2 an indent dragged the item's whole subtree with it. That was
4089
+ // never a decision, it was an artifact: pre-S1 Tab re-parented the <li> and
4090
+ // the nested <ul> travelled inside it, and the flat rewrite reproduced the
4091
+ // observable behaviour rather than changing two things at once.
2374
4092
  //
2375
- // Task 8 replaces the pre-Task-8 "siblings stay" rule. Why adoption is the
2376
- // right shape: the outdented item rises one column, so any item that used to
2377
- // follow it at the OLD level would otherwise have to rise with it (losing
2378
- // its relationship to the item above it) or stay put and become a sibling of
2379
- // the item it used to follow. Notion's answer and the spec's — is that
2380
- // those items keep their exact visual indent and become children of the
2381
- // item that just passed them. That is also the only one of the three
2382
- // outcomes that is a pure re-parenting: no item's rendered indent column
2383
- // changes except `li`'s own.
4093
+ // Spec §3.5 says the opposite, and the user chose it explicitly after seeing
4094
+ // both behaviours side by side: the children keep their own indent and
4095
+ // therefore become the operated item's SIBLINGS. So '- a / - b / (2sp)- b1'
4096
+ // + Tab on b now gives '- a / (2sp)- b / (2sp)- b1', not
4097
+ // '- a / (2sp)- b / (4sp)- b1'. The row-5 scenario in
4098
+ // test/editor-client-runtime.test.js pinned the old expectation and was
4099
+ // migrated with this change.
2384
4100
  //
2385
- // Two hazards this walk is written around (both real against server-rendered
2386
- // list HTML, and both silent if got wrong):
2387
- // 1. The list carries marked's pretty-print "\n" text nodes BETWEEN items.
2388
- // A blanket "remove every following node" loop would drop them (which is
2389
- // harmless — list-md.js treats them as insignificant, see its
2390
- // isBlankText()) but the same loop would ALSO drop any following node
2391
- // that is neither an <li> nor whitespace, i.e. silently delete real
2392
- // content. So only <li>s (collected) and blank text nodes (discarded)
2393
- // are detached; anything else is left exactly where it is. Such a node
2394
- // makes the whole run unsupported anyway (serializeList() flags a
2395
- // non-LI child of a UL/OL), so listRunSupportsStructuralEdit() has
2396
- // already refused the key before this function is reached — this is
2397
- // defense in depth, not a live path.
2398
- // 2. The emptied parent list is removed only when it has no ELEMENT
2399
- // children left — `childElementCount === 0`, i.e. the pre-Task-8 test,
2400
- // which was already right. (`childNodes.length` would NOT be: the
2401
- // leading "\n" text node in front of the moved item always survives, so
2402
- // the list is never empty by NODE count even when it holds nothing.
2403
- // childElementCount counts elements only, so whitespace text nodes are
2404
- // already invisible to it.) The distinction is load-bearing because the
2405
- // two candidate predicates differ in exactly one case — a non-LI ELEMENT
2406
- // left behind in the list — and there childElementCount KEEPS the list,
2407
- // which is what preserves the very node hazard 1 above deliberately
2408
- // declined to move. A "still has an <li> child" test would instead have
2409
- // deleted the list with that node inside it, making hazard 1's care
2410
- // self-defeating.
4101
+ // Nothing replaces the subtree walk: leaving the children alone IS the new
4102
+ // rule, and §3.4's clamp confirms it is legal (a child at old+1 sits under a
4103
+ // parent that is now also at old+1, whose bound is old+2).
2411
4104
  //
2412
- // Adoption target (third silent failure mode): the followers are appended into
2413
- // `li`'s own sublist of the SAME type as the list they came from, creating one
2414
- // if `li` has no matching sublist. Reusing whatever sublist `li` happened to
2415
- // have would rewrite the adopted items' markers a bullet adopted into an
2416
- // <ol> comes back as '1.'. See directNestedListOfType().
2417
- function outdentListItem(li) {
2418
- const parentList = li.parentNode;
2419
- const grandLi = parentList.parentNode;
2420
- if (!grandLi || grandLi.nodeName !== 'LI') return false;
2421
- const grandList = grandLi.parentNode;
2422
- // Notion adoption: former following siblings become `li`'s own children.
2423
- const followers = [];
2424
- let n = li.nextSibling;
2425
- while (n) {
2426
- const next = n.nextSibling;
2427
- if (n.nodeName === 'LI') {
2428
- followers.push(n);
2429
- parentList.removeChild(n);
2430
- } else if (n.nodeType === 3 && /^\s*$/.test(n.textContent)) {
2431
- parentList.removeChild(n); // marked's pretty-print artifact — see hazard 1
2432
- }
2433
- n = next;
2434
- }
2435
- if (followers.length) {
2436
- let sub = directNestedListOfType(li, parentList.nodeName);
2437
- if (!sub) {
2438
- sub = document.createElement(parentList.nodeName === 'OL' ? 'ol' : 'ul');
2439
- li.appendChild(sub);
2440
- }
2441
- followers.forEach((f) => sub.appendChild(f));
2442
- }
2443
- parentList.removeChild(li);
2444
- grandList.insertBefore(li, grandLi.nextSibling);
2445
- if (parentList.childElementCount === 0) grandLi.removeChild(parentList); // see hazard 2
4105
+ // S1: this is integer arithmetic on data-indent, not re-parenting. It
4106
+ // reproduces the pre-S1 semantics exactly there, an item with no previous
4107
+ // <li> SIBLING could not indent, and in the flat model an item whose previous
4108
+ // BLOCK is shallower-or-equal gets the same answer via the clamp (a deeper
4109
+ // previous block belongs to the previous sibling's subtree and only raises
4110
+ // the bound, which the +1 never reaches).
4111
+ //
4112
+ // RULING F-T is now structural rather than defensive: the moved item keeps
4113
+ // its own data-list-type, so it can no longer be silently re-markered by
4114
+ // being appended into a sublist of the other type.
4115
+ function indentListItem(blockEl) {
4116
+ const self = liAttrs(blockEl);
4117
+ if (!self) return false;
4118
+ // Rule (d): the first item of a LIST has nothing above it to nest under.
4119
+ // Without this the §3.4 clamp would happily read the previous list's last
4120
+ // item as "the previous block" and indent this one underneath it, merging
4121
+ // two lists the user never asked to join. Pre-S1 this fell out of
4122
+ // `previousElementSibling` being null inside the item's own <ul>.
4123
+ if (self.listStart) return false;
4124
+ const all = allBlockEls();
4125
+ const i = all.indexOf(blockEl);
4126
+ if (i < 0) return false;
4127
+ const prev = liAttrs(all[i - 1]);
4128
+ const max = prev ? prev.indent + 1 : 0;
4129
+ const next = Math.min(self.indent + 1, max);
4130
+ if (next === self.indent) return false;
4131
+ setBlockIndent(blockEl, next);
2446
4132
  return true;
2447
4133
  }
2448
4134
 
2449
- function handleListKeydown(e, editEl) {
2450
- if (e.key === 'Enter') {
2451
- e.preventDefault();
2452
- if (e.shiftKey) {
2453
- insertBrAtCaret();
2454
- snapBurstIfActive(editEl, 'br');
2455
- return;
2456
- }
2457
- const li = caretListItem(editEl);
2458
- if (!li) return;
2459
- if (selectionSpansMultipleListItems(editEl)) {
2460
- // Refuse rather than silently deleting the spanned content no
2461
- // mutation, no history snap. Collapse to the end of the selection
2462
- // so a repeat Enter (now a plain caret) behaves predictably.
2463
- // (Explicit removeAllRanges()/addRange() — same pattern every other
2464
- // Range-mutation in this file uses rather than mutating the Range
2465
- // returned by getRangeAt() in place, which isn't guaranteed to sync
2466
- // back to the live Selection.)
2467
- const sel = window.getSelection();
2468
- if (sel.rangeCount) {
2469
- const r = sel.getRangeAt(0).cloneRange();
2470
- r.collapse(false);
2471
- sel.removeAllRanges();
2472
- sel.addRange(r);
2473
- }
2474
- return;
2475
- }
2476
- if (isEmptyListItem(li)) {
2477
- removeListItem(li);
2478
- snapBurstIfActive(editEl, 'list-remove');
2479
- editEl.blur(); // ends the burst -> commits, per the brief
2480
- return;
2481
- }
2482
- splitListItemAtCaret(li);
2483
- snapBurstIfActive(editEl, 'list-split');
2484
- return;
2485
- }
2486
- if (e.key === 'Tab') {
2487
- e.preventDefault();
2488
- const li = caretListItem(editEl);
2489
- if (!li) return;
2490
- const changed = e.shiftKey ? outdentListItem(li) : indentListItem(li);
2491
- if (changed) {
2492
- placeCaretAtEnd(li);
2493
- snapBurstIfActive(editEl, e.shiftKey ? 'list-outdent' : 'list-indent');
2494
- }
2495
- return;
2496
- }
2497
- if (e.key === 'Escape') {
2498
- e.preventDefault();
2499
- revertBurstAndEnd(editEl);
2500
- return;
2501
- }
2502
- if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
2503
- e.preventDefault();
2504
- burstUndo(editEl);
2505
- return;
2506
- }
2507
- if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
2508
- e.preventDefault();
2509
- burstRedo(editEl);
2510
- return;
2511
- }
4135
+ // Shift+Tab (spec §11 row 6 / §3.5, user-verified against Notion): the item
4136
+ // rises one level, its OWN subtree rises with it, and its former FOLLOWING
4137
+ // same-level siblings keep their indent — which is exactly what makes them
4138
+ // its children afterwards. Top level (indent 0) -> no-op (row 8).
4139
+ //
4140
+ // S1: the three clauses of §3.5 collapse into two integer writes. Clause 2
4141
+ // (the "adoption" the pre-S1 version implemented by physically re-parenting
4142
+ // every follower into a freshly-created sublist of the matching type) is now
4143
+ // free: leaving the followers' indent alone IS the adoption, and because they
4144
+ // keep their own data-list-type they can no longer come back re-markered as
4145
+ // '1.' — the third silent failure mode the nested implementation had to
4146
+ // hand-guard against. Clause 3 is the same subtree walk indentListItem()
4147
+ // above uses, with delta -1.
4148
+ function outdentListItem(blockEl) {
4149
+ const self = liAttrs(blockEl);
4150
+ if (!self || self.indent === 0) return false;
4151
+ const subtree = subtreeBlocksAfter(blockEl, self.indent);
4152
+ setBlockIndent(blockEl, self.indent - 1);
4153
+ subtree.forEach((el) => {
4154
+ const a = liAttrs(el);
4155
+ if (a) setBlockIndent(el, Math.max(0, a.indent - 1));
4156
+ });
4157
+ return true;
2512
4158
  }
2513
4159
 
2514
4160
  // ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
2515
4161
  // Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
2516
4162
  // 8. Structurally different from Task 4's whole-list handleListKeydown()
2517
- // above (which stays for the legacy 'list' surface): there, a key mutated
2518
- // one big contenteditable and the commit waited for focusout. Here each li
2519
- // is its own block AND its own surface, so a provisional <li> is not a real
4163
+ // (deleted in S1 along with the rest of the legacy 'list' surface): there, a
4164
+ // key mutated one big contenteditable and the commit waited for focusout.
4165
+ // Here each li is its own block AND its own surface, so a provisional block is not a real
2520
4166
  // block until the run is committed and re-rendered — every mutating key
2521
4167
  // therefore commits immediately (spec §3: "any structural change
2522
4168
  // re-serializes the whole run → one line-range replace"), which is also what
@@ -2526,9 +4172,17 @@
2526
4172
  // convertEmptyTopLevelLiToParagraph() below and RULING F-J).
2527
4173
 
2528
4174
  // Shared refusal for a structural key on a run that cannot round-trip —
2529
- // see listRunSupportsStructuralEdit().
2530
- function refuseStructuralListEdit() {
2531
- showBanner('此清單含不支援的格式,無法調整結構', null, null);
4175
+ // see listRunSupportsStructuralEdit(). Also reused (with an explicit
4176
+ // `message` override) by the two blockOwnsNoLine() guards near
4177
+ // openRawEditor() / deleteBlockViaGutter() above — a block that owns no
4178
+ // source line at all is a different reason to refuse than "the run holds
4179
+ // an unsupported format", so it gets its own wording, but there is still
4180
+ // only ONE dismiss-only banner helper: two near-identical refusal
4181
+ // functions in this closure collided once already (Task 4 fix round 1)
4182
+ // and shadowed each other silently (last-declaration-wins), so the
4183
+ // no-source-line callers pass their own text instead of a second function.
4184
+ function refuseStructuralListEdit(message) {
4185
+ showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
2532
4186
  }
2533
4187
 
2534
4188
  // Row 3, top-level press: spec §4 — "at top level the next press converts
@@ -2545,23 +4199,35 @@
2545
4199
  // single undo op. Observed granularity (asserted in
2546
4200
  // test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
2547
4201
  // paragraph without popping the stack, Ctrl+Z #2 reverts the li removal.
2548
- async function convertEmptyTopLevelLiToParagraph(root, li) {
4202
+ async function convertEmptyTopLevelLiToParagraph(runEls, li) {
2549
4203
  // Both captured BEFORE the mutation. The range, because removing the run's
2550
4204
  // last item leaves commitListStructure() nothing to derive it from. The
2551
4205
  // anchor, because a removal never shifts a block that starts ahead of it,
2552
4206
  // and the run's re-serialization only rewrites lines from the run's own
2553
4207
  // start onward — so this startLine survives the commit and is the stable
2554
4208
  // handle back to that block (ids are re-derived by every render).
2555
- const range = runRangeOf({ lines, blocks, stack }, root);
4209
+ const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
2556
4210
  const liBlock = blocks.find((b) => b.id === Number(li.getAttribute('data-block-id')));
4211
+ // `b.id !== liBlock.id` is not redundant: a block that owns no source line
4212
+ // has endLine === startLine - 1, so it satisfies `endLine < startLine`
4213
+ // AGAINST ITSELF and would be picked as its own predecessor. Unreachable
4214
+ // today (such a block is never armed, so this row-3 path cannot start on
4215
+ // one) but it is the same class of bug as the arming one above, and the
4216
+ // guard costs nothing.
2557
4217
  const precedingBlock = liBlock
2558
- ? blocks.filter((b) => b.endLine < liBlock.startLine).pop()
4218
+ ? blocks.filter((b) => b.id !== liBlock.id && b.endLine < liBlock.startLine).pop()
2559
4219
  : null;
4220
+ // S1: the post-mutation span is the pre-mutation one minus the removed
4221
+ // block. It cannot be re-derived from `li` afterwards (the element is
4222
+ // detached), and re-deriving it from a survivor would be wrong for the
4223
+ // last-item case, where the answer must be an EMPTY span (serializes to
4224
+ // '', which is what takes commitListStructure()'s range-removal path).
4225
+ const survivors = runEls.filter((el) => el !== li);
2560
4226
  mutateListRun(() => removeListItem(li));
2561
- // The key's own surface is inside the li that was just removed, so it is
2562
- // detached now commit against the run ROOT (see commitListStructure()'s
2563
- // `runEl` note).
2564
- const ok = await commitListStructure(root, null, false, range);
4227
+ // No `mutatedEl`: `li` is not IN `survivors`, and every block that is was
4228
+ // left exactly as the file has it.
4229
+ const ok = await commitListStructure(survivors, null, false,
4230
+ { presetRange: range, carryOver: bystanderCarryOver(survivors) });
2565
4231
  if (!ok) return;
2566
4232
  // Nothing precedes the removal point (the list opened the document):
2567
4233
  // commitBlockInsertion() can only insert BELOW an existing block, so the
@@ -2585,32 +4251,52 @@
2585
4251
  snapBurstIfActive(editEl, 'br');
2586
4252
  return true;
2587
4253
  }
2588
- const root = listRunRootOf(editEl);
2589
- if (!root) return true;
2590
- // The CARET's li, not editEl's: a run has one editable surface per item,
4254
+ // The CARET's block, not editEl's: a run has one editable surface per item,
2591
4255
  // and the caret can legitimately sit in a different one than the burst was
2592
- // opened on (placing a Range inside another li's surface does not move
2593
- // focus). closestListItem(editEl) is the fallback when the selection is
4256
+ // opened on (placing a Range inside another item's surface does not move
4257
+ // focus). closestLiBlock(editEl) is the fallback when the selection is
2594
4258
  // absent or outside the run.
2595
- const li = caretListItem(root) || closestListItem(editEl, root);
4259
+ const li = caretLiBlock() || closestLiBlock(editEl);
2596
4260
  if (!li) return true;
4261
+ // S1: the commit span, re-derived AFTER each mutation below (an indent
4262
+ // change can move a block between runs). This one is the PRE-mutation span
4263
+ // the gates run against.
4264
+ let run = listRunOf(li);
4265
+ if (!run.length) return true;
2597
4266
 
2598
4267
  if (e.key === 'Tab') {
2599
- if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return true; }
2600
- // Row 5 (Tab): indentListItem() moves ONLY the caret item and its own
2601
- // subtree later siblings are untouched. Row 6 (Shift+Tab):
2602
- // outdentListItem() raises it one level and adopts its former following
2603
- // siblings. Rows 7/8: both return false at their respective boundary (no
2604
- // previous sibling / already top level), which is a complete no-op —
2605
- // nothing mutated, nothing committed, file byte-identical.
2606
- const changed = mutateListRun(() => (e.shiftKey ? outdentListItem(li) : indentListItem(li)));
4268
+ // `columnOnly`: an indent change rewrites nothing but leading columns, so
4269
+ // a hard-wrapped item is allowed to be the target here see
4270
+ // listRunSupportsStructuralEdit()'s deviation note for the measurement.
4271
+ if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
4272
+ refuseStructuralListEdit(); return true;
4273
+ }
4274
+ // Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
4275
+ // children keep their indent and become its siblings. Shift+Tab:
4276
+ // outdentListItem() raises it one level, takes its own subtree with it,
4277
+ // and adopts its former following same-level siblings. Both return false
4278
+ // at their respective boundary (no previous sibling / already top level),
4279
+ // which is a complete no-op — nothing mutated, nothing committed, file
4280
+ // byte-identical.
4281
+ const oldIndent = Number(li.getAttribute('data-indent')) || 0;
4282
+ const changed = mutateListRun(() => {
4283
+ if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
4284
+ applyIndentClamp(run, li, oldIndent);
4285
+ return true;
4286
+ });
2607
4287
  if (!changed) return true;
2608
- commitListStructure(editEl, runLineOfListItem(root, li), true);
4288
+ run = listRunOf(li);
4289
+ // Column-only: nothing's CONTENT moved, so every block in the span —
4290
+ // the target included — is a bystander whose source bytes must come
4291
+ // back untouched. Built once and shared with runLineOfBlock() below,
4292
+ // which indexes into the lines this very map decides.
4293
+ const carry = bystanderCarryOver(run, null);
4294
+ commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
2609
4295
  return true;
2610
4296
  }
2611
4297
 
2612
4298
  // Enter.
2613
- if (selectionSpansMultipleListItems(root)) {
4299
+ if (selectionSpansMultipleListItems()) {
2614
4300
  // Refuse rather than silently deleting the spanned content — no
2615
4301
  // mutation, no commit, no banner. Collapse to the end of the selection
2616
4302
  // so a repeat Enter (now a plain caret) behaves predictably.
@@ -2627,7 +4313,11 @@
2627
4313
  }
2628
4314
  return true;
2629
4315
  }
2630
- if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return true; }
4316
+ // Enter's target is the caret's own item in every one of its three
4317
+ // outcomes (split, empty-outdent, convert-to-paragraph) — each rewrites
4318
+ // that item's own line range, which is exactly what a hard-wrapped item
4319
+ // refuses (spec §4.1).
4320
+ if (!listRunSupportsStructuralEdit(run, li)) { refuseStructuralListEdit(); return true; }
2631
4321
  if (liOwnTextIsBlank(li)) {
2632
4322
  // Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
2633
4323
  // (adoption included). RULING F-Q: an item that OWNS a sublist takes this
@@ -2647,11 +4337,15 @@
2647
4337
  // quirk), and only once the outdent above has actually happened, since a
2648
4338
  // refused press must leave the DOM byte-identical.
2649
4339
  const textEl = liTextEl(li);
2650
- if (textEl !== li && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
4340
+ if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
2651
4341
  return true;
2652
4342
  });
2653
4343
  if (outdented) {
2654
- commitListStructure(editEl, runLineOfListItem(root, li), true);
4344
+ run = listRunOf(li);
4345
+ // `li` is the mutated block (the outdent may have cleared its
4346
+ // surface), so its own bytes are the DOM's, not the file's.
4347
+ const carry = bystanderCarryOver(run, li);
4348
+ commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
2655
4349
  return true;
2656
4350
  }
2657
4351
  // Already at top level, so this is row 3's "next press converts the block
@@ -2662,14 +4356,22 @@
2662
4356
  // did not touch. Refuse instead — a complete no-op (nothing mutated,
2663
4357
  // nothing committed, burst left open) until the user empties or moves the
2664
4358
  // children themselves.
2665
- if (directNestedListOf(li)) return true;
2666
- convertEmptyTopLevelLiToParagraph(root, li);
4359
+ if (liBlockHasChildren(li)) return true;
4360
+ convertEmptyTopLevelLiToParagraph(run, li);
2667
4361
  return true;
2668
4362
  }
2669
4363
  // Row 1: split at the caret; the caret goes to the START of the new block.
2670
4364
  const newLi = mutateListRun(() => splitListItemAtCaret(li));
2671
4365
  if (!newLi) return true;
2672
- commitListStructure(editEl, runLineOfListItem(root, newLi), false);
4366
+ run = listRunOf(newLi);
4367
+ // `li` had its text CUT IN TWO in the DOM; replaying its source would put
4368
+ // the whole of it back and duplicate the half that moved into `newLi`.
4369
+ // Named explicitly rather than leaning on the dirty-burst exclusion: the
4370
+ // caret can sit in a different item than the burst was opened on (see
4371
+ // where `li` is derived above), and then the burst names the wrong block.
4372
+ // `newLi` is provisional (no data-block-id) and excludes itself.
4373
+ const carry = bystanderCarryOver(run, li);
4374
+ commitListStructure(run, runLineOfBlock(run, newLi, carry), false, { carryOver: carry });
2673
4375
  return true;
2674
4376
  }
2675
4377
 
@@ -3167,8 +4869,17 @@
3167
4869
  if (!document.body.contains(tableEl)) {
3168
4870
  const liveBlockEl = startLine != null ? blockElAtLine(startLine) : null;
3169
4871
  liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
3170
- if (!liveTableEl || !liveTableEl.classList || !liveTableEl.classList.contains('ed-wys-table')) return null;
3171
- if (identity == null || tableIdentityOf(liveTableEl) !== identity) return null;
4872
+ // T7: both refusals used to `return null` in silence — the drag, the
4873
+ // insert, the alignment change simply did not happen and nothing on
4874
+ // screen said why, which is indistinguishable from a broken control.
4875
+ // Dropping is still the right answer (see the S1 comment above); saying
4876
+ // nothing was not.
4877
+ if (!liveTableEl || !liveTableEl.classList || !liveTableEl.classList.contains('ed-wys-table')) {
4878
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
4879
+ }
4880
+ if (identity == null || tableIdentityOf(liveTableEl) !== identity) {
4881
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
4882
+ }
3172
4883
  }
3173
4884
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
3174
4885
  const cell = tableCellsOf(liveTableEl)[0];
@@ -3308,8 +5019,8 @@
3308
5019
  // grips below are the fix: real, adequately-sized (≥18×24px) elements the
3309
5020
  // user can actually see and aim for. Both grips are overlay elements
3310
5021
  // `position: fixed`-appended to document.body — never DOM CHILDREN of a
3311
- // contenteditable cell, even though the row grip now visually overlaps one
3312
- // (P0-a moved it just INSIDE the table's left border; see the geometry
5022
+ // contenteditable cell, even though the row grip's inner half visually
5023
+ // overlaps one (it straddles the table's left border; see the geometry
3313
5024
  // note in the "Notion-style row/column grip handles" section below). So —
3314
5025
  // unlike the old zones, which
3315
5026
  // sat INSIDE an already-permanently-contenteditable cell and needed the
@@ -3323,8 +5034,9 @@
3323
5034
  // row gets a grip, the header <tr> included (spec §3.10/§4.6: in markdown a
3324
5035
  // table's first row IS its header, so position alone decides header
3325
5036
  // identity — dragging a data row above the header PROMOTES it, and the old
3326
- // header becomes a data row). The header's grip is offset DOWN by
3327
- // TE_HEADER_GRIP_DY_PX so it stays clear of the column-grip band.
5037
+ // header becomes a data row). EVERY row's grip the header's included —
5038
+ // uses the SAME geometry: centred on the table's left border, vertically
5039
+ // centred on its own row. There is no per-row-type special case.
3328
5040
  //
3329
5041
  // After a small movement threshold (distinguishing "click to open the
3330
5042
  // menu" from "press-and-drag"), a drop-indicator line tracks the pointer
@@ -3612,9 +5324,8 @@
3612
5324
  // hovering any cell of — EVERY row, the header included (spec §3.10: the
3613
5325
  // header is draggable too, since position alone decides header identity;
3614
5326
  // only its CLICK differs, highlighting instead of opening the
3615
- // delete-only menu). On the header row the grip is additionally offset
3616
- // DOWN by TE_HEADER_GRIP_DY_PX so it does not collide with the column
3617
- // grip's own band above the table; `colGrip` is a horizontal 6-dot handle
5327
+ // delete-only menu) positioned identically on every row, header
5328
+ // included; `colGrip` is a horizontal 6-dot handle
3618
5329
  // shown just ABOVE whichever column the pointer is hovering (every
3619
5330
  // column, header included — the column menu's delete/align both apply to
3620
5331
  // header cells too). Built once by buildTableGrip() below and driven by
@@ -3622,15 +5333,20 @@
3622
5333
  // listener (wired near the bottom of this file) that already drives
3623
5334
  // updateTableInsertBubbles() — see its own comment for the coalescing
3624
5335
  // contract this reuses.
3625
- // Review fix (P0-a): neither grip is separated from the table any more.
3626
- // The COLUMN grip's centerline coincides with the table's top edge, so its
3627
- // hit rect straddles that border by ~half its own height on each side. The
3628
- // ROW grip sits fully INSIDE the table instead its LEFT edge on the
3629
- // table's left border, extending inward because the space just outside
3630
- // that border belongs to the block's own gutter (spec §4.2 衝突 2). The
3631
- // visible consequence of that choice is that the leftmost ~20px (the row
3632
- // grip's own CSS width) of the first column is covered by the grip, so a
3633
- // click there does not place the caret; it is a known, accepted trade.
5336
+ // Review fix (P0-a) + user acceptance (uniform geometry): neither grip is
5337
+ // separated from the table, and both use the SAME rule the grip's own
5338
+ // CENTRELINE coincides with the table border it belongs to, so its hit
5339
+ // rect straddles that border by half its own size on each side. COLUMN
5340
+ // grip: centred on the table's TOP edge. ROW grip: centred on the table's
5341
+ // LEFT edge. An earlier revision insetted the row grip fully INSIDE the
5342
+ // table to dodge the block's own gutter ⠿; that was reverted the
5343
+ // occupies only the block's top ~20px while a row grip sits at its own
5344
+ // row's mid-height, so the two never actually intersect, and the inset put
5345
+ // the grip on top of the first cell's TEXT (user-acceptance defect). The
5346
+ // gutter is given its own room in CSS instead (`.content { padding-left }`
5347
+ // plus `.ed-handle/.ed-insert { left: -36px }`, both edit-mode-only — see
5348
+ // lib/md2doc.js), so the straddling grip reaches only into the first
5349
+ // cell's PADDING, never its text.
3634
5350
  // Either way the grip's hit rect DOES overlap the insert bubble's hit
3635
5351
  // rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
3636
5352
  // own axis). Non-intersection via rect separation is no longer possible
@@ -3698,17 +5414,6 @@
3698
5414
  gripColIndex = null;
3699
5415
  }
3700
5416
 
3701
- // spec §4.2 衝突 2/3:row grip 一律落在表格**內側**(dx=0,左緣貼齊表格
3702
- // 左邊界向內延伸),避開頁面 gutter 的 ⠿;表頭列的 grip 額外**往下**
3703
- // 偏移,避開 colGrip 佔住的 [tableTop−12, tableTop+12] 帶。往左或往上
3704
- // 都會重新製造衝突,所以只有這一個方向。
3705
- // updateTableEdgeGrips() 與 pointInRowGripZone() 共用這張表——兩邊各算
3706
- // 一次就是 grip「看得到但按不到」的經典成因。
3707
- const TE_HEADER_GRIP_DY_PX = 16;
3708
- function rowGripOffsetFor(rowEl, tableEl) {
3709
- return { dx: 0, dy: rowEl === headerRowOf(tableEl) ? TE_HEADER_GRIP_DY_PX : 0 };
3710
- }
3711
-
3712
5417
  // Bug fix (user acceptance) — history: grips were originally BOTH
3713
5418
  // border-straddling (P0-a), and were visible on hover but UNREACHABLE by a
3714
5419
  // real pointer. Root cause — a pointer travelling from inside a cell
@@ -3720,15 +5425,12 @@
3720
5425
  // gripCenter(), which jump straight to the grip's own coordinates) could
3721
5426
  // ever land on it; a real mouse gesture could not.
3722
5427
  //
3723
- // Current geometry (spec §4.2 衝突 2, this file's row-grip-inside-table
3724
- // change above): the ROW grip no longer straddles the border its left
3725
- // edge sits ON the table's left border, extending INWARD, fully inside the
3726
- // table (see rowGripOffsetFor() above). A pointer travelling from a cell to
3727
- // the row grip's centre therefore never leaves the table, so the
3728
- // corridor-crossing bug described above no longer applies to the row grip.
3729
- // The COLUMN grip is UNCHANGED and still straddles the table's top border
3730
- // (half above the border, half below), so the corridor-crossing bug above
3731
- // still applies to it exactly as originally described.
5428
+ // Current geometry (uniform, both axes): BOTH grips straddle their own
5429
+ // table border the row grip half outside / half inside the table's LEFT
5430
+ // border, the column grip half above / half below its TOP border. So the
5431
+ // corridor-crossing bug described above applies to BOTH of them exactly as
5432
+ // originally described, and both keep-zones below cover the corresponding
5433
+ // outside-the-border corridor.
3732
5434
  //
3733
5435
  // Review fix (Important, first pass over-permissive): the first version of
3734
5436
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -3741,15 +5443,14 @@
3741
5443
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
3742
5444
  // pointInColGripZone() below) instead of the whole table.
3743
5445
  //
3744
- // What each keep-zone covers TODAY: pointInRowGripZone() is the union of
3745
- // (the row grip's own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel
3746
- // rounding) and (the vertical span between the grip and its anchor row —
3747
- // needed because a header-row grip, offset DOWN by TE_HEADER_GRIP_DY_PX,
3748
- // can sit below the row's own bottom edge; see rowGripOffsetFor() above).
3749
- // pointInColGripZone() keeps the ORIGINAL corridor shape: the straight
3750
- // strip between the grip's own top edge and the table's top border, x
3751
- // clamped to the anchor column's own horizontal extent, padded — because
3752
- // the column grip still straddles the border.
5446
+ // What each keep-zone covers TODAY: both keep the ORIGINAL corridor shape,
5447
+ // mirrored per axis. pointInRowGripZone() is the union of (the row grip's
5448
+ // own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel rounding) and (the
5449
+ // straight strip between the grip's own LEFT edge and the table's LEFT
5450
+ // border, y clamped to the anchor ROW's own vertical extent, padded).
5451
+ // pointInColGripZone() is the same with the axes swapped: the strip
5452
+ // between the grip's own top edge and the table's top border, x clamped to
5453
+ // the anchor COLUMN's own horizontal extent, padded.
3753
5454
  // A pointer outside either grip's own zone is a genuine exit and still
3754
5455
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
3755
5456
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -3771,15 +5472,13 @@
3771
5472
  !document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
3772
5473
  const gr = rowGrip.getBoundingClientRect();
3773
5474
  if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
3774
- // grip 現在在表格內側,所以「從儲存格走向 grip」全程都在表格上,由
3775
- // updateTableEdgeGrips() onValidCell 分支處理。這裡只需要涵蓋
3776
- // grip 自己的矩形、以及它與所錨定那一列之間的垂直落差(表頭 grip 被
3777
- // 往下偏移,可能超出該列的上下緣)。
5475
+ // grip 跨在表格左邊界上,所以「從儲存格走向 grip」必定經過邊界外側的
5476
+ // 那半個 grip 寬度。走廊=從 grip 自己的左緣到表格左緣,垂直方向夾在
5477
+ // 所錨定那一列的上下緣(加 TE_GRIP_ZONE_PAD_PX 的次像素寬容)。
3778
5478
  const rowRect = gripRowEl.getBoundingClientRect();
3779
- const top = Math.min(rowRect.top, gr.top) - TE_GRIP_ZONE_PAD_PX;
3780
- const bottom = Math.max(rowRect.bottom, gr.bottom) + TE_GRIP_ZONE_PAD_PX;
3781
- return x >= gr.left - TE_GRIP_ZONE_PAD_PX && x <= gr.right + TE_GRIP_ZONE_PAD_PX &&
3782
- y >= top && y <= bottom;
5479
+ const tableRect = gripRowTableEl.getBoundingClientRect();
5480
+ return x >= gr.left && x <= tableRect.left &&
5481
+ y >= rowRect.top - TE_GRIP_ZONE_PAD_PX && y <= rowRect.bottom + TE_GRIP_ZONE_PAD_PX;
3783
5482
  }
3784
5483
 
3785
5484
  function pointInColGripZone(x, y) {
@@ -3808,10 +5507,9 @@
3808
5507
  function updateTableEdgeGrips(x, y, target) {
3809
5508
  // Both grips are `position: fixed` overlays appended to document.body
3810
5509
  // (same as the hover-insert bubbles) rather than descendants of the
3811
- // table — regardless of whether they PAINT outside it (the column grip,
3812
- // straddling the top border) or inside it (the row grip, whose left edge
3813
- // sits on the table's left border and which extends inward over the
3814
- // first column). So the moment the real pointer crosses from a cell onto
5510
+ // table — both PAINT half outside it (the column grip straddling the top
5511
+ // border, the row grip straddling the left border). So the moment the
5512
+ // real pointer crosses from a cell onto
3815
5513
  // the grip itself, `target` is the grip and is no
3816
5514
  // longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
3817
5515
  // Without this guard, that transition would hit the "nothing found"
@@ -3845,8 +5543,9 @@
3845
5543
 
3846
5544
  // Row grip: every row, including the header — the first row of a
3847
5545
  // markdown table IS the header, so any row must be draggable to the
3848
- // top to become it (rowGripOffsetFor() shifts the header's own grip
3849
- // down, clear of the column-grip band, so the two never collide). The
5546
+ // top to become it. ONE position rule for all of them (user acceptance:
5547
+ // 「grip 位置都一樣」) centred on the table's left border, vertically
5548
+ // centred on its own row; no header special case. The
3850
5549
  // one exception is a header-only table (no body rows): its single row
3851
5550
  // is thead's only row, and dragging it away would empty the thead —
3852
5551
  // serializeTable() would degrade it and the user's table would vanish
@@ -3859,10 +5558,10 @@
3859
5558
  // (20x28) — offsetWidth/Height read 0 while `hidden` (display: none)
3860
5559
  // is still true on the FIRST show of a hover session, before the
3861
5560
  // `hidden = false` assignment below takes effect.
5561
+ const gw = rowGrip.offsetWidth || 20;
3862
5562
  const gh = rowGrip.offsetHeight || 28;
3863
- const off = rowGripOffsetFor(rowEl, tableEl);
3864
- rowGrip.style.left = (tableRect.left + off.dx) + 'px';
3865
- rowGrip.style.top = (r.top + r.height / 2 - gh / 2 + off.dy) + 'px';
5563
+ rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
5564
+ rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
3866
5565
  rowGrip.hidden = false;
3867
5566
  } else {
3868
5567
  gripRowTableEl = null;
@@ -4719,11 +6418,22 @@
4719
6418
  const checkEl = e.target.closest && e.target.closest('.ed-li-check');
4720
6419
  if (checkEl) {
4721
6420
  e.preventDefault();
4722
- const li = checkEl.closest('li.ed-block');
6421
+ const li = closestLiBlock(checkEl);
4723
6422
  if (!li) return;
4724
- const root = listRunRootOf(checkEl);
4725
- if (!root) return;
4726
- if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return; }
6423
+ const run = listRunOf(li);
6424
+ if (!run.length) return;
6425
+ // The toggle rewrites the checkbox INSIDE this item's own marker, so
6426
+ // the item is the operation target (spec §4.1) — but by §4.1's own
6427
+ // CRITERION (see listRunSupportsStructuralEdit()'s note) it is a
6428
+ // COLUMN-ONLY operation and therefore not one of the refusals: it
6429
+ // changes no content, no line count and no column at all ('[ ] ' and
6430
+ // '[x] ' are the same width, so §3.4's colDelta is exactly 0). Without
6431
+ // this a hard-wrapped task item — which on a real to-do list is most
6432
+ // of them — answered '此清單含不支援的格式,無法調整結構' to a click
6433
+ // on its own checkbox.
6434
+ if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
6435
+ refuseStructuralListEdit(); return;
6436
+ }
4727
6437
  // Resolve any open burst on another block before mutating. The span
4728
6438
  // is non-focusable, so mousedown on it does NOT steal focus — the
4729
6439
  // currently-focused surface's focusout never fires, and currentBurst
@@ -4736,22 +6446,31 @@
4736
6446
  if (!ok) return;
4737
6447
  // Re-find the li and its checkbox after the potential re-render.
4738
6448
  const targetLi = targetBlockId
4739
- ? document.querySelector('li.ed-block[data-block-id="' + targetBlockId + '"]')
6449
+ ? document.querySelector(
6450
+ '.ed-block[data-block-type="li"][data-block-id="' + targetBlockId + '"]')
4740
6451
  : null;
4741
6452
  const targetCheck = targetLi && targetLi.querySelector(':scope > .ed-li-check');
4742
6453
  if (!targetCheck) return;
4743
6454
  // Re-gate on the post-render DOM in case the burst resolution
4744
6455
  // changed the run's supported status.
4745
- const targetRoot = listRunRootOf(targetCheck);
4746
- if (!targetRoot) return;
4747
- if (!listRunSupportsStructuralEdit(targetRoot)) { refuseStructuralListEdit(); return; }
6456
+ const targetRun = listRunOf(targetLi);
6457
+ if (!targetRun.length) return;
6458
+ if (!listRunSupportsStructuralEdit(targetRun, targetLi, { columnOnly: true })) {
6459
+ refuseStructuralListEdit(); return;
6460
+ }
4748
6461
  // Flip state, then serialize the whole run as one undo op.
4749
6462
  const wasChecked = targetCheck.getAttribute('data-checked') === '1';
4750
6463
  targetCheck.setAttribute('data-checked', wasChecked ? '0' : '1');
4751
6464
  targetCheck.setAttribute('aria-checked', String(!wasChecked));
4752
6465
  // focusStartLine = null: a checkbox click is not a caret gesture;
4753
6466
  // leave focus wherever the post-commit re-render puts it.
4754
- await commitListStructure(blockContentEl(targetLi), null, false);
6467
+ // Column-only, so no `mutatedEl`: the flipped state travels in the
6468
+ // re-stated MARKER (list-md.js builds '[x] ' as part of it, and
6469
+ // splitSourceMarkers() strips the old one off the replayed line), and
6470
+ // everything after that marker — this item's own continuation lines
6471
+ // included — comes back byte-for-byte.
6472
+ await commitListStructure(targetRun, null, false,
6473
+ { carryOver: bystanderCarryOver(targetRun, null) });
4755
6474
  return;
4756
6475
  }
4757
6476
  // ⠿ handle: toggles its menu for the block it belongs to. ⠿ menu: its
@@ -4772,6 +6491,28 @@
4772
6491
  if (insertMenuBlockEl) closeInsertMenu();
4773
6492
  if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
4774
6493
  let blockEl = e.target.closest('.ed-block');
6494
+ // v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
6495
+ // the 40px gutter part of the block's HIT area so that hovering it keeps
6496
+ // the +/⠿ pair visible. That is a hover fix, and it must not become a
6497
+ // click fix by accident: before it, a click in the gutter band hit
6498
+ // main.content and meant "clicked outside any block" — which for a
6499
+ // DEGRADED block (blockquote, fenced code, an unsupported table) is the
6500
+ // difference between committing whatever was open and silently opening
6501
+ // that block's raw source editor from 20px away from it.
6502
+ //
6503
+ // Read only when the click landed on the block's OWN box (a click on any
6504
+ // descendant — the text surface, a marker, a checkbox, a gutter button —
6505
+ // is unaffected) and only when the event actually carries coordinates:
6506
+ // a synthesized `new MouseEvent('click', {bubbles:true})` and
6507
+ // `el.click()` both report clientX/clientY 0, which several scenarios in
6508
+ // test/editor-client-runtime.test.js use precisely because they mean
6509
+ // "the block itself", not "a point". `offsetX < 0` looks like the
6510
+ // tidier test and is NOT usable: for a synthesized event Chromium still
6511
+ // derives offsetX from clientX 0, so it comes back as minus the block's
6512
+ // whole left offset and every such click reads as a gutter click.
6513
+ if (blockEl && e.target === blockEl && (e.clientX || e.clientY)) {
6514
+ if (e.clientX < blockEl.getBoundingClientRect().left) blockEl = null;
6515
+ }
4775
6516
  if (!blockEl) { await switchAwayFrom(); return; } // clicked outside any block
4776
6517
 
4777
6518
  // Task 5: a table block is now armed exactly like paragraph/heading/
@@ -4948,6 +6689,44 @@
4948
6689
  return;
4949
6690
  }
4950
6691
 
6692
+ // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
6693
+ // branch above is keyed on the event target being some edit surface, and
6694
+ // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
6695
+ // the block's own gutter, focus is on BODY and the target is BODY — so no
6696
+ // branch matched and the browser ran its own sequential focus navigation,
6697
+ // landing on whichever gutter <button> happens to come next in document
6698
+ // order. Spec §3.5: 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點
6699
+ // 巡覽. This is deliberately a silent no-op rather than "indent the block
6700
+ // nearest the caret": with no focus there is no caret, so there is no
6701
+ // block the key could mean.
6702
+ //
6703
+ // Scoped so a real control keeps its keyboard contract: the reader's own
6704
+ // search input and the raw editor's textarea (which returned above) are
6705
+ // still tabbable, and so is anything else the user has deliberately
6706
+ // focused. What is swallowed is Tab from inside a `.ed-block` and Tab with
6707
+ // no focus at all — the two states the editor puts the user in.
6708
+ if (e.key === 'Tab') {
6709
+ const inBlock = e.target && e.target.closest && e.target.closest('.ed-block');
6710
+ // A REAL control inside a block keeps its keyboard contract. The raw
6711
+ // source editor's own 完成/取消 buttons are the case that matters: Tab
6712
+ // out of its textarea is how a keyboard user reaches them (the textarea
6713
+ // itself returned above), and swallowing the next Tab would trap focus
6714
+ // on the button it just landed on. The ⠿ menu's buttons are the same
6715
+ // shape. The two GUTTER buttons are excluded from that exemption on
6716
+ // purpose — they are the chrome this fix exists to keep out of the tab
6717
+ // order, and buildGutterHandle()/buildGutterInsertButton() give them
6718
+ // tabindex="-1" for the same reason.
6719
+ const control = e.target && e.target.closest && e.target.closest(
6720
+ 'button:not(.ed-handle):not(.ed-insert), input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])');
6721
+ const focused = document.activeElement;
6722
+ const nothingFocused = !focused || focused === document.body ||
6723
+ focused === document.documentElement;
6724
+ if ((inBlock && !control) || nothingFocused) {
6725
+ e.preventDefault();
6726
+ return;
6727
+ }
6728
+ }
6729
+
4951
6730
  if (e.key === 'Escape') {
4952
6731
  e.preventDefault();
4953
6732
  closeGutterMenu();