@helping-ai-workflow/md2doc 2.11.1 → 3.0.0

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.
@@ -174,6 +174,291 @@
174
174
  return { lines: r.lines, blocks, op, newStartLine };
175
175
  }
176
176
 
177
+ // ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
178
+ //
179
+ // A move is ONE commitRangeEdit over
180
+ // `min(source, destination) .. max(source, destination)`. UndoStack's op is
181
+ // `{startLine, endLine, before, after}` — a SINGLE contiguous line range,
182
+ // with no notion of a source and a destination — so a move written as
183
+ // "removal here, insertion there" is two ops and two Ctrl+Z, which §3.4
184
+ // forbids. For a block that is not a li there is nothing to re-serialize:
185
+ // the lines are relocated VERBATIM, so nothing can be re-escaped and no
186
+ // marker arithmetic is involved.
187
+ //
188
+ // THE BLANK-LINE RULE, which the plan left to this task to define:
189
+ //
190
+ // 1. The block's own lines move byte-for-byte.
191
+ // 2. At the seam it LEAVES, the blank run that surrounded it is absorbed
192
+ // and replaced by exactly ONE blank line — or by none when the block
193
+ // was at the start or the end of the file.
194
+ // 3. At the seam it LANDS in, one blank line is emitted on each side of
195
+ // it whose neighbouring line is not already blank.
196
+ //
197
+ // Neither existing helper is the right tool, and the plan says so:
198
+ // commitRangeRemoval() absorbs exactly one adjacent blank and
199
+ // commitBlockInsertion() always adds a leading one. Each is HALF of a move
200
+ // and the halves do not compose — the pair leaves the document one
201
+ // separator short at the seam that was left and one long at the seam that
202
+ // was landed in, and it is two undo ops.
203
+ //
204
+ // CONSEQUENCE, measured against marked.lexer + blockmap.buildBlockMap on
205
+ // every fixture in test/editor-client-runtime.test.js's T3 section before
206
+ // any byte was pinned: in a document whose blocks are separated by exactly
207
+ // one blank line — everything this editor's own serializer emits — a move
208
+ // is a PERMUTATION OF THE FILE'S LINES. Same lines, same count, same
209
+ // separators.
210
+ //
211
+ // The one exception, and it is deliberate: a document that already jams two
212
+ // blocks onto adjacent lines ('# A\n# B\n# C\n') has no blank at the
213
+ // landing seam to carry, so rule 3 CREATES one and the count goes up. It
214
+ // has to. MEASURED: 'paraA\n# H\n\nparaB\n' with paraA appended and no
215
+ // blank emitted is '# H\n\nparaB\nparaA\n', which marked.lexer answers as
216
+ // ONE paragraph — the move would have eaten a block. The count moves only
217
+ // in that direction, and only at a seam that had no separator to begin
218
+ // with.
219
+ //
220
+ // The final '' of a file that ends in a newline is the TERMINATOR, not a
221
+ // blank separator; `contentLen` below is what keeps rule 2 from eating the
222
+ // file's last newline when the block being moved is the last one.
223
+ //
224
+ // Pure: takes and returns plain data, and lives in the node-visible core
225
+ // beside the other commit helpers.
226
+ function planBlockMove(lines, blocks, src, dest) {
227
+ const isBlank = (s) => s !== undefined && s !== null && s.trim() === '';
228
+ const contentLen = lines.length - (lines.length && lines[lines.length - 1] === '' ? 1 : 0);
229
+ let above = 0;
230
+ while (src.startLine - 1 - above >= 1 && isBlank(lines[src.startLine - 2 - above])) above++;
231
+ let below = 0;
232
+ while (src.endLine + 1 + below <= contentLen && isBlank(lines[src.endLine + below])) below++;
233
+ // The source's own extraction range: its lines plus the whole blank run on
234
+ // either side of it. What goes BACK at that seam is rule 2's single blank,
235
+ // and only when the seam has a block on both sides of it.
236
+ const ds = src.startLine - above;
237
+ const de = src.endLine + below;
238
+ const srcFill = (ds >= 2 && de < contentLen) ? [''] : [];
239
+ const blockLines = lines.slice(src.startLine - 1, src.endLine);
240
+ // `dest === null` is the {mode:'append'} target: the insertion point is
241
+ // one line past the last block's last line.
242
+ const ins = dest ? dest.startLine
243
+ : (blocks.length ? blocks[blocks.length - 1].endLine + 1 : contentLen + 1);
244
+ // Dropped where it already is. `ins === ds` is "on my own top edge";
245
+ // `ins === de + 1` is "on the seam immediately below me" — visually the
246
+ // same place, and the byte no-op the drag's own release-where-you-started
247
+ // case depends on. Every other `ins` inside the extraction range is a
248
+ // blank line, which no block's startLine can name.
249
+ if (ins >= ds && ins <= de + 1) return null;
250
+ const prevLine = ins >= 2 ? lines[ins - 2] : null;
251
+ const nextLine = ins <= lines.length ? lines[ins - 1] : null;
252
+ const lead = (prevLine !== null && !isBlank(prevLine)) ? [''] : [];
253
+ const trail = (nextLine !== null && !isBlank(nextLine)) ? [''] : [];
254
+ const payload = lead.concat(blockLines, trail);
255
+ // ONE range, covering both seams. Everything between them is carried
256
+ // through untouched — which is what makes this a single undo op instead
257
+ // of a removal and an insertion.
258
+ if (ins > de + 1) {
259
+ const after = srcFill.concat(lines.slice(de, ins - 1), payload);
260
+ const newStart = ds + srcFill.length + (ins - 1 - de) + lead.length;
261
+ return { startLine: ds, endLine: ins - 1, after: after,
262
+ newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
263
+ }
264
+ const after = payload.concat(lines.slice(ins - 1, ds - 1), srcFill);
265
+ const newStart = ins + lead.length;
266
+ return { startLine: ins, endLine: de, after: after,
267
+ newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
268
+ }
269
+
270
+ // The commit half. `op === null` with `noop` set means the block was
271
+ // released where it already was — a BYTE no-op, not a failure, and
272
+ // deliberately distinct from commitRangeEdit()'s own unchanged-text
273
+ // `op: null` so a caller can tell "nothing to do" from "somebody handed me
274
+ // a range that produced the same text".
275
+ function commitBlockMove(state, src, dest) {
276
+ const plan = planBlockMove(state.lines, state.blocks, src, dest);
277
+ if (!plan) {
278
+ return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
279
+ }
280
+ const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'));
281
+ r.newRange = plan.newRange;
282
+ return r;
283
+ }
284
+
285
+ // ── S4 Task 4: the pure half of a LIST ITEM's move (spec §4.5) ─────────
286
+ //
287
+ // A li's lines CANNOT be relocated the way planBlockMove() relocates every
288
+ // other block's — verbatim. Markers and ordinals are RUN-GLOBAL: '3. charlie'
289
+ // moved to the head of its run has to come back as '1. charlie' with
290
+ // everything behind it renumbered, and its indent prefix is a column count
291
+ // measured against the marker widths standing above it. So a li's move is
292
+ // expressed the way duplicateListItems() expresses a duplicate — as a
293
+ // REORDERED SPAN ARRAY handed to serializeBlocks(), committed over the run's
294
+ // own line range as ONE commitRangeEdit (§3.4: one gesture, one undo op).
295
+ // These two functions are the parts of that reasoning with no DOM in them.
296
+
297
+ // The span order after "the `count` members starting at `from` go
298
+ // immediately before the member at `insertAt`". `insertAt` names a slot in
299
+ // the array BEFORE the removal — it comes straight from the drop target's
300
+ // `before-block N` — so a DOWNWARD move is decremented by the WHOLE count
301
+ // and an upward one is not decremented at all: the same `insertAt` means two
302
+ // different slots depending on the direction, which is the whole reason this
303
+ // is a named function with its own test rather than a splice pair at the
304
+ // call site.
305
+ //
306
+ // S4 Task 6 widened it from one member to `count`, in place rather than
307
+ // beside: §4.5's 「grip 在選取集合內 → 整批搬」 is the SAME arithmetic and a
308
+ // second copy of an off-by-one is the 「不得另寫一條」 shape §3.6 has already
309
+ // ruled against twice. The single-item case is `count === 1` and its whole
310
+ // test table was migrated onto this signature unchanged.
311
+ //
312
+ // `null` means the order would be UNCHANGED, which is a byte no-op and not a
313
+ // failure: every `insertAt` from `from` to `from + count` inclusive is the
314
+ // SET'S OWN FOOTPRINT — `from` is "released on my own top edge",
315
+ // `from + count` is "released on the seam immediately below me", and the
316
+ // slots in between name a position INSIDE the set, which is not a reorder of
317
+ // the span but a shuffle of members that are travelling together. Visually
318
+ // they are all the same place, and they are the case the drag's own
319
+ // release-where-you-started gesture depends on. An out-of-range index, an
320
+ // empty set and a set that runs off the end answer `null` too; there is no
321
+ // order any of them could name.
322
+ function reorderSpanRange(length, from, count, insertAt) {
323
+ if (!(length > 0) || !(count > 0)) return null;
324
+ if (!(from >= 0) || from + count > length) return null;
325
+ if (!(insertAt >= 0) || insertAt > length) return null;
326
+ if (insertAt >= from && insertAt <= from + count) return null;
327
+ const rest = [];
328
+ const moved = [];
329
+ for (let i = 0; i < length; i++) {
330
+ if (i >= from && i < from + count) moved.push(i); else rest.push(i);
331
+ }
332
+ const at = insertAt > from ? insertAt - count : insertAt;
333
+ return rest.slice(0, at).concat(moved, rest.slice(at));
334
+ }
335
+
336
+ // ── S4 Task 6: the LINE RANGE a batch move relocates (spec §3.3, §4.5) ──
337
+ //
338
+ // §3.3's operand set is CONTIGUOUS in block order — resolveGutterOperands()
339
+ // refuses a gap before anything reaches here — so its members' lines plus
340
+ // the separators standing between them are ONE contiguous run, which is
341
+ // exactly what planBlockMove() relocates verbatim. That is the whole reason
342
+ // a batch move is the SAME single commitRangeEdit a one-block move is, and
343
+ // therefore the same single Ctrl+Z (§3.4).
344
+ //
345
+ // Named rather than written as an object literal at the call site because
346
+ // the set reaches the bytes through this one expression and nothing else: an
347
+ // endLine taken from any member but the LAST leaves the rest of the set
348
+ // standing where it was while its head travels, and the blank-line rules
349
+ // then re-emit separators around both halves — a document the user never
350
+ // asked for, from a one-token typo. A pure function is a mutation a test can
351
+ // kill in a millisecond instead of a browser round trip.
352
+ function spanMoveRange(recs) {
353
+ if (!recs || !recs.length) return null;
354
+ return { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine };
355
+ }
356
+
357
+ // §4.5's INDENT SEAM, asked of the span AS IT WOULD BE COMMITTED.
358
+ //
359
+ // Markdown indent is only ever RELATIVE (indent-clamp.js's own opening
360
+ // sentence): an item's depth exists solely because a shallower item stands
361
+ // above it. serializeBlocks() rebuilds its marker-width stack as it walks the
362
+ // array, from empty — so a span whose FIRST block claims `data-indent="1"`
363
+ // emits that block at column 0, and the DOM and the file then disagree about
364
+ // the nesting. A move creates exactly that by lifting an item out from above
365
+ // its own child.
366
+ //
367
+ // The bound is deliberately the one indent-clamp.js's `boundAt()` computes
368
+ // — the previous li's indent + 1, and 0 when there is no previous li or the
369
+ // previous block is not one — so the predicate and the clamp cannot drift.
370
+ //
371
+ // S4 Task 7 KEPT it and moved it to AFTER the clamp. Asked of the raw
372
+ // reordered span it refused 1425 of the 4067 legal drops; the clamp now
373
+ // answers 1411 of those, and what this still refuses is the 14 no
374
+ // removal-clamp can reach — a batch whose last member is shallower than the
375
+ // block it lands before, i.e. the INSERTION half of the move. See
376
+ // performListItemDrop() for the single site and BLOCK_MOVE_ORPHAN_MESSAGE
377
+ // for the family and the sweep behind those numbers.
378
+ function spanIndentsAreAnchored(items) {
379
+ let prevLi = null;
380
+ for (let i = 0; i < (items || []).length; i++) {
381
+ const it = items[i] || {};
382
+ if (it.type !== 'li') { prevLi = null; continue; }
383
+ const indent = typeof it.indent === 'number' ? it.indent : 0;
384
+ if (indent > (prevLi === null ? 0 : prevLi + 1)) return false;
385
+ prevLi = indent;
386
+ }
387
+ return true;
388
+ }
389
+
390
+ // ── S4 Task 5: the CROSS-BOUNDARY seam predicate (spec §4.5) ──────────
391
+ //
392
+ // §4.5's ruling, made by the user on 2026-09-01 and implemented rather than
393
+ // re-opened: a cross-boundary move is REFUSED with a banner in 3.0.0 and the
394
+ // capability deferred to 3.1.0, whose first step is amending the spec with
395
+ // the blank-line rule for a run/non-run seam. This function is the whole of
396
+ // "which moves cross a boundary" for a block that is NOT a list item; a li
397
+ // source has its own, narrower question (its destination must be a slot in
398
+ // its own §3.8 run) and answers it in performListItemDrop().
399
+ //
400
+ // A move touches TWO seams and they need DIFFERENT rules. That asymmetry is
401
+ // the task's finding, and every row below is a marked.lexer measurement:
402
+ //
403
+ // DESTINATION — where the block LANDS. An insertion can only ever SPLIT;
404
+ // it has no way to merge two lists. So the only thing it can break is a
405
+ // run it lands INSIDE, and the question is exactly "are these two
406
+ // neighbours members of ONE run":
407
+ // '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para` spliced
408
+ // between the items -> list(1) | paragraph | list(1): one two-item run
409
+ // became two one-item runs, and the blanks rule 3 emits either side
410
+ // are §4.3's looseness trap. REFUSED.
411
+ // '# Doc\n\n- a\n\n1. b\n\ntail\n' with `para` spliced at the
412
+ // same-looking seam -> heading | ul(1,tight) | paragraph |
413
+ // ol(1,tight) | paragraph. Nothing merged, nothing loosened, because
414
+ // these were already two lists. ACCEPTED — this is Task 3's
415
+ // deliberate over-refusal, and narrowing it is what Task 5 owed.
416
+ //
417
+ // SOURCE — where the block LEAVES. A removal MERGES, and it CANNOT be
418
+ // narrowed with anything the block model carries. Every one of these
419
+ // leaves ONE list with `loose === true`, i.e. every item renders as a <p>,
420
+ // serializeBlocks() reports 'P' for each, and the whole run degrades
421
+ // read-only with no banner:
422
+ // '- a' | para | '- b' -> '- a\n\n- b\n'
423
+ // '1. a' | para | '2. b' -> '1. a\n\n2. b\n'
424
+ // '- [ ] a' | para | '- [x] b' -> '- [ ] a\n\n- [x] b\n'
425
+ // '- a' | para | ' 1. a1' -> '- a\n\n 1. a1\n'
426
+ // The last one is why the obvious narrowings are all wrong. blockmap
427
+ // reports those two items as (indent 0, listType 'ul') and (indent 0,
428
+ // listType 'ol') — DIFFERENT runs AND different list types, so every fact
429
+ // the model exposes says "two lists, safe", while the file says loose.
430
+ // What decides it is the two-space prefix on a raw line, which no block
431
+ // attribute carries and which the client cannot lex (render is a server
432
+ // round trip; there is no lexer in the browser). The mirror over-refusal
433
+ // is '- a' | para | '* b' -> '- a\n\n* b\n', measurably TWO tight
434
+ // lists and still refused, because the discriminator there is the bullet
435
+ // CHARACTER — another raw byte the model does not carry. Recorded in §4.5
436
+ // as a deliberate over-refusal for 3.1.0, not as a correctness claim.
437
+ //
438
+ // `srcSeam` / `destSeam` are `{prev, next}`; each side is null when there is
439
+ // no such block or it is not a li, and otherwise `{runKey}` — the
440
+ // `data-block-id` of the FIRST member of listRunOf(), which is §3.4 rule 2's
441
+ // scope, so a nested child and its parent answer the SAME key. A null
442
+ // runKey (a run the DOM could not answer) counts as "same": an unknown seam
443
+ // refuses rather than guesses, because the guess that goes wrong corrupts a
444
+ // list and corrupting a list is not undoable.
445
+ //
446
+ // Returns 'source' | 'destination' | null. When both seams object the
447
+ // SOURCE one is reported: it is the objection that holds wherever the user
448
+ // aims next, so it is the one worth telling them about.
449
+ //
450
+ // Pure, and exported, because the enumeration it encodes is a table of
451
+ // measurements — a gesture-level test can only reach a handful of its rows.
452
+ function blockMoveSeamRefusal(srcSeam, destSeam) {
453
+ const s = srcSeam || {};
454
+ const d = destSeam || {};
455
+ if (s.prev && s.next) return 'source';
456
+ if (d.prev && d.next &&
457
+ (d.prev.runKey === null || d.next.runKey === null ||
458
+ d.prev.runKey === d.next.runKey)) return 'destination';
459
+ return null;
460
+ }
461
+
177
462
  // ── T8 review MEDIUM-1: the one correct way to undo a failed render ─────
178
463
  // Six sites in this file share the shape "commit optimistically, re-render,
179
464
  // and if the render failed put `lines` back". The inlined version of that
@@ -222,7 +507,7 @@
222
507
  }
223
508
 
224
509
  if (typeof module === 'object' && module.exports) {
225
- module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, rollbackFailedRender, headingDepthOf, withHeadingDepth };
510
+ module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
226
511
  return; // node: pure core only
227
512
  }
228
513
 
@@ -233,6 +518,7 @@
233
518
  const listMd = window.md2docListMd;
234
519
  const indentClamp = window.md2docIndentClamp;
235
520
  const convertMd = window.md2docConvertMd;
521
+ const selectionLib = window.md2docSelection;
236
522
  const historyLib = window.md2docHistory;
237
523
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
238
524
  // 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
@@ -440,8 +726,19 @@
440
726
  // extra button ahead of the always-present ✕ dismiss button (e.g. the
441
727
  // conflict banner's "Reload"); omit them for a plain dismiss-only notice.
442
728
  let activeBanner = null;
729
+ // Review recommendation 5 (2026-08-31): whether the banner on screen is a
730
+ // structural REFUSAL — 「無法整批操作」 and its siblings — as opposed to a
731
+ // conflict / render-failed / save-failed notice. Only a refusal is cleared
732
+ // by a later gesture that succeeds (see dismissRefusalBanner()); the other
733
+ // three describe the state of the FILE or the connection and must stay until
734
+ // the user dismisses them.
735
+ let activeBannerIsRefusal = false;
443
736
  function showBanner(message, actionLabel, onAction) {
444
737
  if (activeBanner) { activeBanner.remove(); activeBanner = null; }
738
+ // Reset unconditionally: refuseStructuralListEdit() sets it back to true
739
+ // straight after its own call, so every OTHER caller gets false without
740
+ // having to know this flag exists.
741
+ activeBannerIsRefusal = false;
445
742
  const el = document.createElement('div');
446
743
  el.className = 'ed-conflict';
447
744
  const msg = document.createElement('span');
@@ -460,7 +757,7 @@
460
757
  dismissBtn.setAttribute('aria-label', 'Dismiss');
461
758
  dismissBtn.addEventListener('click', () => {
462
759
  el.remove();
463
- if (activeBanner === el) activeBanner = null;
760
+ if (activeBanner === el) { activeBanner = null; activeBannerIsRefusal = false; }
464
761
  });
465
762
  el.appendChild(dismissBtn);
466
763
  document.body.appendChild(el);
@@ -468,6 +765,24 @@
468
765
  return el;
469
766
  }
470
767
 
768
+ // Review recommendation 5: a refusal banner must not outlive the gesture that
769
+ // raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
770
+ // S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
771
+ // refuse), so 「選取範圍同時含有清單項目與其他區塊,無法整批操作」 could stand
772
+ // over a document the user had since successfully edited — a plain lie about
773
+ // the state of the document. Called from rerenderAll()'s success path, which
774
+ // is the one point every structural operation reaches only by having WORKED:
775
+ // every failure exit above it returns before this. Deliberately NOT called
776
+ // from the Escape / clear-selection paths — dismissing a selection is not the
777
+ // same event as a later gesture succeeding, and the refusal is still the true
778
+ // answer to the gesture the user last attempted.
779
+ function dismissRefusalBanner() {
780
+ if (!activeBanner || !activeBannerIsRefusal) return;
781
+ activeBanner.remove();
782
+ activeBanner = null;
783
+ activeBannerIsRefusal = false;
784
+ }
785
+
471
786
  function showConflictBanner() {
472
787
  showBanner(
473
788
  'File changed on disk — reload to pick up external edits ' +
@@ -503,6 +818,14 @@
503
818
  // await is inside its own try/catch, so callers never see a rejection.
504
819
  async function rerenderAll() {
505
820
  const scrollY = window.scrollY;
821
+ // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
822
+ // for the rebuilt selection. Consumed HERE, before the first failure exit,
823
+ // rather than down at the rebuild itself: every `return false` below
824
+ // leaves `blockSelection` untouched — which is what makes a line-range
825
+ // selection survive a failed render for free — and a declaration left
826
+ // standing would then land on some LATER, unrelated render instead.
827
+ const declaredRange = pendingSelectionRange;
828
+ pendingSelectionRange = undefined;
506
829
  let res;
507
830
  try {
508
831
  res = await fetch('/api/render', {
@@ -530,6 +853,10 @@
530
853
  showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
531
854
  return false;
532
855
  }
856
+ // Past every failure exit: this render is going to happen, so whatever
857
+ // gesture asked for it has succeeded. Review recommendation 5 — a stale
858
+ // structural refusal is cleared HERE and nowhere else.
859
+ dismissRefusalBanner();
533
860
  blocks = j.blocks;
534
861
  contentEl.innerHTML = j.bodyHtml;
535
862
  // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
@@ -593,6 +920,11 @@
593
920
  // ago was just destroyed by the innerHTML replacement above.
594
921
  hideTableEdgeMenu();
595
922
  cancelTeDrag();
923
+ // S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
924
+ // the innerHTML replacement above just detached every block the gesture's
925
+ // ordinal refers to. Nothing was mutated by the drag, so there is nothing
926
+ // to revert; the state simply must not survive the render.
927
+ cancelBlockDrag();
596
928
  // Final-review Finding 5b (Important): same belt-and-braces reasoning
597
929
  // again for the ⠿ gutter menu — `toggleGutterMenu()` appends the ONE
598
930
  // shared `gutterMenu` node as a CHILD of whichever block it's open for
@@ -611,6 +943,17 @@
611
943
  // it's the same "singleton node appended as a child of whichever block
612
944
  // it's open for" idiom as gutterMenu just above.
613
945
  closeInsertMenu();
946
+ // S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
947
+ // 1; this is steps 2 and 3 — rebuild the member set from the declared (or
948
+ // still-standing) LINE RANGE against the freshly built `blocks`, clear it
949
+ // if that range no longer resolves, and give the focus endpoint a real
950
+ // roving-tabindex holder. It sits AFTER the unconditional teardown above
951
+ // so nothing there can null what it just set, and BEFORE the two
952
+ // `try`-swallowed rebind blocks below so a diagram-init throw can never
953
+ // skip it. It is also deliberately ABOVE window.scrollTo(): the .focus()
954
+ // it ends in scrolls the holder into view, and the scroll restore below
955
+ // is what puts the reader back where they were.
956
+ rebuildBlockSelection(declaredRange);
614
957
  window.scrollTo(0, scrollY);
615
958
  if (window.__md2docInitDiagrams) {
616
959
  try {
@@ -1294,23 +1637,66 @@
1294
1637
  // has to: `opIndex` is an index into it, and rule 2's scope starts after it);
1295
1638
  // clampIndents() reports no indent for a removed block, so the write-back
1296
1639
  // 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;
1640
+ //
1641
+ // S3 Task 6: `opBlockEl` may also be an ARRAY — spec §3.4 rule 3's multi-block
1642
+ // operation, which clampIndents() has accepted since S1 (`opIndex` may be an
1643
+ // array of indices). `opOldIndent` is then the SMALLEST old indent in the set,
1644
+ // never the first member's: §3.4 rule 3 records both ways the first member
1645
+ // goes wrong (a delete drives a later member to indent −1; a batch Tab whose
1646
+ // first member is already at its ceiling no-ops the whole set). The caller
1647
+ // computes it with spanMinIndent(). A member that is not in `spanEls` aborts
1648
+ // the whole clamp rather than clamping a subset — a partial op set makes rule
1649
+ // 2's scope start in the wrong place, which is a silent wrong answer.
1650
+ // S4 Task 7 split this in two WITHOUT changing what any existing caller does:
1651
+ // computeIndentClamp() answers the clamp, applyIndentClamp() writes it, and
1652
+ // the four callers that existed before this task still call the latter and
1653
+ // still see exactly the answer they saw. The reason for the split is the ⠿
1654
+ // DROP, and it is not a stylistic one: every other caller clamps a span it
1655
+ // has already decided to commit, while the drop still has ONE refusal left
1656
+ // after the clamp (see performListItemDrop()). Writing `data-indent` on a
1657
+ // gesture that then refuses leaves a half-clamped DOM behind — the same
1658
+ // "the screen and the model disagree" failure the clamp exists to prevent,
1659
+ // produced by the clamp itself. So the drop asks first and writes only once
1660
+ // it knows it is committing.
1661
+ //
1662
+ // `null` is "no answer at all": no clamp module, an empty span, or an
1663
+ // operated element that is not IN the span. That last one abandons the whole
1664
+ // clamp rather than clamping a subset — a partial op set makes rule 2's scope
1665
+ // start in the wrong place, which is a silent wrong answer. Every caller
1666
+ // treats `null` as "leave every indent alone", which is what the pre-split
1667
+ // function did by returning early.
1668
+ function computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1669
+ if (!indentClamp || !spanEls || !spanEls.length) return null;
1670
+ const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
1671
+ const opIndex = [];
1672
+ for (let k = 0; k < opEls.length; k++) {
1673
+ const at = spanEls.indexOf(opEls[k]);
1674
+ if (at < 0) return null;
1675
+ opIndex.push(at);
1676
+ }
1677
+ if (!opIndex.length) return null;
1301
1678
  const model = spanEls.map((el, i) => ({
1302
1679
  id: i, // index-as-id: the span IS the universe here
1303
1680
  type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
1304
1681
  indent: Number(el.getAttribute('data-indent')) || 0,
1305
1682
  }));
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);
1683
+ return indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {})
1684
+ .map((r) => ({ el: spanEls[r.blockId], indent: r.indent }))
1685
+ .filter((r) => !!r.el);
1686
+ }
1687
+
1688
+ function writeIndentClamp(answer) {
1689
+ (answer || []).forEach((r) => {
1690
+ if ((Number(r.el.getAttribute('data-indent')) || 0) !== r.indent) {
1691
+ setBlockIndent(r.el, r.indent);
1310
1692
  }
1311
1693
  });
1312
1694
  }
1313
1695
 
1696
+ function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1697
+ writeIndentClamp(computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts));
1698
+ }
1699
+
1314
1700
  function placeCaretAtEnd(el) {
1315
1701
  const range = document.createRange();
1316
1702
  range.selectNodeContents(el);
@@ -1596,35 +1982,175 @@
1596
1982
  // without a second selector.
1597
1983
  let convertSubmenu = null;
1598
1984
 
1985
+ // ── v2.12.0 Task 4b, half 1: an icon at the head of every item ──────────
1986
+ // User request: 「選單每個功能開頭給一個圖示,完全照抄 notion」. Notion's
1987
+ // visual LANGUAGE, drawn here rather than their assets copied: 16x16 on a
1988
+ // 0 0 16 16 viewBox, 1.5px stroke, fill:none, round caps and joins, and
1989
+ // stroke="currentColor" so an icon is simply the colour of the row it sits
1990
+ // in — retheme .ed-handle-menu and the icons follow for free, which a hex
1991
+ // literal here would break silently.
1992
+ //
1993
+ // The menu is a SINGLETON built once at module scope and moved between
1994
+ // blocks, so this markup is parsed exactly once for the life of the page.
1995
+ // Nothing here may run per open.
1996
+ const MENU_ICON_PATHS = {
1997
+ // 轉換成 — a turn/redirect arrow: out to the right, then down. "this block
1998
+ // becomes that one".
1999
+ convert: '<path d="M2.5 4.5h6a3 3 0 0 1 3 3v4.6"/><path d="M9 9.6l2.5 2.5 2.5-2.5"/>',
2000
+ // 建立副本 — two offset rounded cards, the standard duplicate glyph. The
2001
+ // back card is an L-shaped outline rather than a second full rect so the
2002
+ // two do not draw a line through each other.
2003
+ duplicate: '<rect x="5.5" y="5.5" width="8" height="8" rx="2"/>' +
2004
+ '<path d="M10.5 5.5V4.5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h1"/>',
2005
+ // 刪除 — a trash can: lid, handle, tapered body, two ribs.
2006
+ trash: '<path d="M2.5 4.5h11"/>' +
2007
+ '<path d="M6.4 4.5V3.2a1.2 1.2 0 0 1 1.2-1.2h.8a1.2 1.2 0 0 1 1.2 1.2v1.3"/>' +
2008
+ '<path d="M4.2 4.5l.6 8.1a1.4 1.4 0 0 0 1.4 1.3h3.6a1.4 1.4 0 0 0 1.4-1.3l.6-8.1"/>' +
2009
+ '<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
2010
+ // MD 原始碼 — angle brackets: source, not prose.
2011
+ code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
2012
+ };
2013
+ function menuIconMarkup(name) {
2014
+ return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
2015
+ 'aria-hidden="true" focusable="false" fill="none" stroke="currentColor" ' +
2016
+ 'stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
2017
+ MENU_ICON_PATHS[name] + '</svg>';
2018
+ }
2019
+
2020
+ // ── v2.12.0 Task 4b, half 2: the submenu opens on HOVER ─────────────────
2021
+ // User request: 「"轉換成" hover 沒有自動顯示子選單」.
2022
+ //
2023
+ // The naive "open on mouseenter, close on mouseleave" reproduces the gutter
2024
+ // corridor defect v2.11.1 had just finished fixing, and MEASURED on this
2025
+ // branch at 1400x900 it is worse than the plan predicted:
2026
+ //
2027
+ // * `.ed-handle-submenu { left: 100%; margin-left: 4px }` over a menu with
2028
+ // 4px of padding leaves x in [item.right + 4, sub.left) — exactly 4
2029
+ // device px on the item's own row — hit-testing to the BLOCK underneath.
2030
+ // A mouseleave-closes rule shuts the panel while the pointer is inside
2031
+ // that band, on its way in.
2032
+ // * The far worse one: the submenu is 342px tall against the menu's 118px,
2033
+ // so a straight line from 轉換成 to the panel's LAST row is 344px long
2034
+ // and leaves the item/panel pair for 334ms at 1200px/s, 367ms at 600,
2035
+ // 535ms at 300 and 1068ms at 150 — first across 建立副本 / 刪除 /
2036
+ // MD 原始碼, then across bare page BELOW the menu. No close delay covers
2037
+ // that AND still shuts the panel promptly when the user really has
2038
+ // settled on 刪除; the plan's suggested 150-250ms covers neither end.
2039
+ //
2040
+ // So the rule is the classic menu-aim one, and it is a DIRECTION test rather
2041
+ // than a distance or a timer: while the pointer is moving INTO the panel —
2042
+ // inside the triangle whose apex is where it was one sample ago and whose
2043
+ // base is the panel's near (left) edge, top to bottom — nothing closes the
2044
+ // submenu, whatever it happens to be passing over on the way. Every other
2045
+ // sample (a different parent item, bare page, off the menu entirely)
2046
+ // SCHEDULES the close, and the grace period below is only what covers a
2047
+ // single sample the triangle misses. Escape / outside click /
2048
+ // closeGutterMenu() are unchanged and still immediate.
2049
+ const SUBMENU_CLOSE_MS = 300;
2050
+ let submenuCloseTimer = null;
2051
+ let submenuAimPrev = null;
2052
+ // Whether the standing panel was opened by the pointer rather than by a
2053
+ // click — see the 轉換成 item's own handler for what it is for.
2054
+ let convertSubmenuViaHover = false;
2055
+
2056
+ function cancelSubmenuClose() {
2057
+ if (submenuCloseTimer !== null) { clearTimeout(submenuCloseTimer); submenuCloseTimer = null; }
2058
+ }
2059
+ function scheduleSubmenuClose() {
2060
+ // Deliberately NOT restarted while one is already counting down: the
2061
+ // countdown starts at the first sample that says "not on the way in", and
2062
+ // any sample that says otherwise cancels it outright. Restarting per
2063
+ // mousemove would make the close time depend on how much the user jiggles.
2064
+ if (submenuCloseTimer !== null) return;
2065
+ submenuCloseTimer = setTimeout(() => {
2066
+ submenuCloseTimer = null;
2067
+ closeConvertSubmenu();
2068
+ }, SUBMENU_CLOSE_MS);
2069
+ }
2070
+ function pointInTriangle(px, py, ax, ay, bx, by, cx, cy) {
2071
+ const cross = (x1, y1, x2, y2, x3, y3) => (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
2072
+ const d1 = cross(px, py, ax, ay, bx, by);
2073
+ const d2 = cross(px, py, bx, by, cx, cy);
2074
+ const d3 = cross(px, py, cx, cy, ax, ay);
2075
+ return !(((d1 < 0) || (d2 < 0) || (d3 < 0)) && ((d1 > 0) || (d2 > 0) || (d3 > 0)));
2076
+ }
2077
+ function onSubmenuPointerMove(e) {
2078
+ if (!convertSubmenu) return;
2079
+ const prev = submenuAimPrev;
2080
+ submenuAimPrev = { x: e.clientX, y: e.clientY };
2081
+ const t = e.target;
2082
+ const inSub = !!(t && t.closest && t.closest('.ed-handle-submenu'));
2083
+ const btn = t && t.closest ? t.closest('.ed-handle-menu-btn') : null;
2084
+ if (inSub || (btn && btn === gutterMenuConvert)) { cancelSubmenuClose(); return; }
2085
+ if (prev) {
2086
+ const r = convertSubmenu.getBoundingClientRect();
2087
+ if (pointInTriangle(e.clientX, e.clientY, prev.x, prev.y, r.left, r.top, r.left, r.bottom)) {
2088
+ cancelSubmenuClose();
2089
+ return;
2090
+ }
2091
+ }
2092
+ scheduleSubmenuClose();
2093
+ }
2094
+
1599
2095
  function buildGutterMenu() {
1600
2096
  const el = document.createElement('div');
1601
2097
  el.className = 'ed-handle-menu';
1602
2098
 
1603
- function item(label, aria, onClick) {
2099
+ function item(label, aria, onClick, icon) {
1604
2100
  const b = document.createElement('button');
1605
2101
  b.type = 'button';
1606
2102
  b.className = 'ed-handle-menu-btn';
1607
- b.textContent = label;
2103
+ // The icon first, then the label as a bare TEXT NODE. Never
2104
+ // `b.textContent = label` after this (it would wipe the icon), and
2105
+ // deliberately no wrapper element around the label: every menu helper in
2106
+ // this repo and in test/editor-client-runtime.test.js finds an item by
2107
+ // EXACT `b.textContent`, and an <svg> contributes none of its own, so the
2108
+ // label reads back byte-identical with the icon in front of it.
2109
+ b.innerHTML = menuIconMarkup(icon);
2110
+ b.appendChild(document.createTextNode(label));
1608
2111
  b.setAttribute('aria-label', aria);
1609
2112
  b.addEventListener('click', onClick);
1610
2113
  el.appendChild(b);
1611
2114
  return b;
1612
2115
  }
1613
2116
 
2117
+ // Hover-open. One delegated listener on the singleton menu, for the life of
2118
+ // the page. The submenu is a CHILD of the menu so its own mouseovers bubble
2119
+ // through here too, which is why this is an IDENTITY check against
2120
+ // gutterMenuConvert rather than a label match.
2121
+ el.addEventListener('mouseover', (e) => {
2122
+ const btn = e.target && e.target.closest ? e.target.closest('.ed-handle-menu-btn') : null;
2123
+ if (!gutterMenuConvert || btn !== gutterMenuConvert || gutterMenuConvert.hidden) return;
2124
+ cancelSubmenuClose();
2125
+ if (!convertSubmenu) openConvertSubmenu(gutterMenuConvert, true);
2126
+ });
2127
+
1614
2128
  // 轉換成 is the one item that does NOT close the menu — it grows a
1615
2129
  // submenu, and a second press folds it back up.
1616
2130
  gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
1617
2131
  e.stopPropagation();
1618
- if (convertSubmenu) { closeConvertSubmenu(); return; }
1619
- openConvertSubmenu(gutterMenuConvert);
1620
- });
2132
+ if (convertSubmenu) {
2133
+ // A click folds a CLICK-opened panel back up — the S2 toggle, which is
2134
+ // the path clickGutterMenuItem() / convertVia() drive throughout the
2135
+ // test suite and which must not regress. It must NOT fold up a panel
2136
+ // the pointer's own hover just opened: the pointer is on the item, so
2137
+ // no further mouseover would ever fire, and the panel would become
2138
+ // unreachable by mouse — the exact opposite of what was asked for.
2139
+ // The click does consume the hover flag, so a second one still folds.
2140
+ if (!convertSubmenuViaHover) { closeConvertSubmenu(); return; }
2141
+ convertSubmenuViaHover = false;
2142
+ cancelSubmenuClose();
2143
+ return;
2144
+ }
2145
+ openConvertSubmenu(gutterMenuConvert, false);
2146
+ }, 'convert');
1621
2147
 
1622
2148
  gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
1623
2149
  e.stopPropagation();
1624
2150
  const blockEl = gutterMenuBlockEl;
1625
2151
  closeGutterMenu();
1626
2152
  duplicateBlockViaMenu(blockEl);
1627
- });
2153
+ }, 'duplicate');
1628
2154
 
1629
2155
  // §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
1630
2156
  // unchanged (that function was already fully block-type-agnostic —
@@ -1638,14 +2164,14 @@
1638
2164
  const blockEl = gutterMenuBlockEl;
1639
2165
  closeGutterMenu();
1640
2166
  deleteBlockViaGutter(blockEl);
1641
- });
2167
+ }, 'trash');
1642
2168
 
1643
2169
  gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
1644
2170
  e.stopPropagation();
1645
2171
  const blockEl = gutterMenuBlockEl;
1646
2172
  closeGutterMenu();
1647
2173
  openRawViaGutter(blockEl);
1648
- });
2174
+ }, 'code');
1649
2175
 
1650
2176
  return el;
1651
2177
  }
@@ -1657,7 +2183,7 @@
1657
2183
  // `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
1658
2184
  // no viewport arithmetic, and the panel travels with the menu when the menu
1659
2185
  // is moved into another block.
1660
- function openConvertSubmenu(anchorBtn) {
2186
+ function openConvertSubmenu(anchorBtn, viaHover) {
1661
2187
  closeConvertSubmenu();
1662
2188
  const sub = document.createElement('div');
1663
2189
  sub.className = 'ed-handle-menu ed-handle-submenu';
@@ -1678,9 +2204,22 @@
1678
2204
  sub.style.top = anchorBtn.offsetTop + 'px';
1679
2205
  anchorBtn.parentNode.appendChild(sub);
1680
2206
  convertSubmenu = sub;
2207
+ convertSubmenuViaHover = !!viaHover;
2208
+ // The aim triangle needs a previous sample to have an apex; the first
2209
+ // mousemove after the open only records one. Cleared here so a panel
2210
+ // re-opened on another block cannot aim from the old block's geometry.
2211
+ submenuAimPrev = null;
2212
+ // Attached only while a panel is standing, and removed again below — a
2213
+ // document-level mousemove listener that outlived the panel would be a
2214
+ // per-move cost on every page for nothing.
2215
+ document.addEventListener('mousemove', onSubmenuPointerMove);
1681
2216
  }
1682
2217
 
1683
2218
  function closeConvertSubmenu() {
2219
+ cancelSubmenuClose();
2220
+ submenuAimPrev = null;
2221
+ convertSubmenuViaHover = false;
2222
+ document.removeEventListener('mousemove', onSubmenuPointerMove);
1684
2223
  if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
1685
2224
  }
1686
2225
 
@@ -1696,6 +2235,46 @@
1696
2235
  gutterMenuBlockEl = null;
1697
2236
  }
1698
2237
 
2238
+ // ── The one rule about 轉換成 and block type ────────────────────────────
2239
+ // The block types 轉換成 is WITHHELD from, and the banner the BATCH path
2240
+ // shows when a span holds one. ONE table, TWO call sites — toggleGutterMenu()
2241
+ // just below hides the item on a single block's ⠿; convertBlockViaMenu()
2242
+ // refuses a span that holds one — and that is the point of it being a shared
2243
+ // predicate rather than a type test written out twice. MEASURED 2026-08-31,
2244
+ // with the batch path gating only 'table': a selection over a paragraph, an
2245
+ // hr and a paragraph, converted from a grip on a paragraph, wrote
2246
+ // '# Doc\n\n- alpha\n- ---\n- bravo\n' with NO banner — '- ---' being the
2247
+ // exact byte sequence the 'hr' reason below names as why the item is not
2248
+ // offered. A type withheld in one place and silently allowed in the other IS
2249
+ // the defect; deriving both from here is what makes a fourth type impossible
2250
+ // to add to one affordance and forget in the other.
2251
+ //
2252
+ // Why these three:
2253
+ // 'table' — §7: there is no target that could carry a table's cells, and
2254
+ // every one of the twelve would destroy them.
2255
+ // 'hr' / 'html' — the gesture would LIE. convert-md strips a block's
2256
+ // MARKER to get its content, and an <hr> has no content: its source line
2257
+ // IS the marker. Measured: 'hr' → 項目符號列表 writes '- ---', which
2258
+ // marked re-lexes as an hr again, so the file's bytes change, the block
2259
+ // type does not, and nothing is said; 'hr' → 文字 is a byte no-op, also
2260
+ // silent. An 'html' block is raw passthrough for the same reason — no
2261
+ // marker to strip and nothing to re-host. Nothing is LOST either way,
2262
+ // but an item that appears to work and does nothing is worse than an
2263
+ // item that is not offered.
2264
+ //
2265
+ // Each type names ITSELF in the banner: a user whose selection holds an hr
2266
+ // must not be told it holds a table. One template, three labels — the
2267
+ // wording is per type, the RULE is not.
2268
+ const BATCH_CONVERT_WITHHELD_MESSAGES = {
2269
+ table: '選取範圍含有表格,無法整批轉換',
2270
+ hr: '選取範圍含有分隔線,無法整批轉換',
2271
+ html: '選取範圍含有 HTML 區塊,無法整批轉換',
2272
+ };
2273
+ function convertWithheldFor(blockType) {
2274
+ return Object.prototype.hasOwnProperty.call(
2275
+ BATCH_CONVERT_WITHHELD_MESSAGES, blockType);
2276
+ }
2277
+
1699
2278
  function toggleGutterMenu(blockEl) {
1700
2279
  if (!blockEl) return;
1701
2280
  if (gutterMenuBlockEl === blockEl) { closeGutterMenu(); return; }
@@ -1712,21 +2291,10 @@
1712
2291
  closeConvertSubmenu();
1713
2292
  gutterMenuBlockEl = blockEl;
1714
2293
  const blockType = blockEl.getAttribute('data-block-type');
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');
2294
+ // Spec §7 / §3.7. Which types, and why, is stated ONCE just above — this
2295
+ // is one of that predicate's two call sites, and convertBlockViaMenu()'s
2296
+ // batch refusal is the other.
2297
+ gutterMenuConvert.hidden = convertWithheldFor(blockType);
1730
2298
  gutterMenuDuplicate.hidden = false;
1731
2299
  gutterMenuDelete.hidden = false;
1732
2300
  // RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
@@ -1738,7 +2306,28 @@
1738
2306
  // between blocks, so this must be reset on every open, not set once.
1739
2307
  // (test/editor-reader-rebind.test.js drives raw-edit through this button
1740
2308
  // by its exact text on a paragraph.)
1741
- gutterMenuMd.hidden = (blockType === 'li');
2309
+ //
2310
+ // §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
2311
+ // is the half S3 shipped without. openRawViaGutter() rewrites ONE block's
2312
+ // source lines, so over a set of N it silently answers for the grip's
2313
+ // block and ignores the rest: an item that appears to act on the selection
2314
+ // and does not.
2315
+ //
2316
+ // The question is §3.3's MEMBERSHIP question, asked exactly the way every
2317
+ // set operation asks it (resolveGutterOperands() → resolveMembership(),
2318
+ // identifying the grip block by REFERENCE into `blocks` — Task 1 carry 4),
2319
+ // and not "is anything selected". Two states answer differently and both
2320
+ // keep the item: a set of exactly the grip block is a set of ONE, which
2321
+ // raw-edits fine; and a set standing elsewhere in the document is not this
2322
+ // grip's set at all — §3.3 turns that gesture into a single-block operation
2323
+ // before it acts, so the item is right there too.
2324
+ const mdRec = blockRecOf(blockEl);
2325
+ const mdMembership = (selectionLib && mdRec)
2326
+ ? selectionLib.resolveMembership(blockSelection, blocks, mdRec)
2327
+ : { mode: 'single', members: mdRec ? [mdRec] : [] };
2328
+ const mdMultiSelected =
2329
+ mdMembership.mode === 'batch' && mdMembership.members.length > 1;
2330
+ gutterMenuMd.hidden = (blockType === 'li') || mdMultiSelected;
1742
2331
  blockEl.appendChild(gutterMenu);
1743
2332
  }
1744
2333
 
@@ -2165,97 +2754,365 @@
2165
2754
  // li -> heading would hit the degrade path and refuse itself. Reading `lines`
2166
2755
  // also means the inline content is never re-serialized, so escapeText() never
2167
2756
  // runs over it and a `~5px` in the converted block stays `~5px`.
2168
- async function convertBlockViaMenu(blockEl, target) {
2169
- if (!blockEl || !target) return;
2757
+ // ── S3 Task 6: the ⠿ gesture's OPERAND SET (spec §3.3) ──────────────────
2758
+ //
2759
+ // Up to S2 every gutter operation worked on exactly one block and the three
2760
+ // entry points below carried a byte-identical preamble: capture identity,
2761
+ // resolve whatever session is open, re-find the block if that commit
2762
+ // re-rendered, refuse a block that owns no source line. §3.3 turns that ONE
2763
+ // element into a SPAN — 「grip 在選取集合內 → 作用整個集合;grip 在集合外 →
2764
+ // 先把集合換成該單一 block 再作用」— so the preamble moved here and grew a
2765
+ // membership step.
2766
+ //
2767
+ // ORDERING IS LOAD-BEARING. Membership is resolved AFTER switchAwayFrom(),
2768
+ // never before: that call can commit an open burst, and its rerenderAll()
2769
+ // renumbers every block id and hands back a fresh `blocks` array. A record
2770
+ // captured ahead of it is a dangling reference — and resolveMembership()
2771
+ // compares by REFERENCE (Task 1 carry 4: ids are forbidden architecturally
2772
+ // and line tuples are genuinely ambiguous, since `- - - a` yields two
2773
+ // structurally identical phantoms {startLine:1, endLine:0}), so a stale
2774
+ // record silently answers 'single' and the batch degrades to one block. The
2775
+ // selection itself survives that render for free — its identity is a LINE
2776
+ // RANGE, which is exactly what no render can invalidate.
2777
+ //
2778
+ // Returns `null` when the gesture is refused or dropped — the banner has
2779
+ // already been raised — otherwise `{ els, recs, batch }`: the LIVE block
2780
+ // elements in document order, their records out of `blocks` (by reference),
2781
+ // and whether more than one block is being operated on.
2782
+ async function resolveGutterOperands(blockEl) {
2783
+ if (!blockEl) return null;
2784
+ // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
2785
+ // alive across the ⠿ press, so the commit that lands inside
2786
+ // switchAwayFrom() below can be a rewrite of the very block the gesture
2787
+ // names — in which case reresolveBlockEl()'s SOURCE fingerprint is
2788
+ // guaranteed to miss, because WE are the reason the source changed. That
2789
+ // is not a dropped gesture; startLine + type still name the block, and the
2790
+ // fingerprint's job (proving an UNRELATED commit did not move somebody
2791
+ // else into this slot) is done by those two here.
2170
2792
  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
2793
  const selfSession = ownsOpenSession(blockEl);
2181
2794
  const ok = await switchAwayFrom();
2182
- if (!ok) return;
2795
+ if (!ok) return null;
2183
2796
  let liveBlockEl = blockEl;
2184
2797
  if (!document.body.contains(blockEl)) {
2185
2798
  liveBlockEl = reresolveBlockEl(identity) ||
2186
2799
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2187
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2800
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
2801
+ }
2802
+ // A block that owns no source line has an INVERTED range
2803
+ // (endLine === startLine - 1) and every commit helper handed one does
2804
+ // something plausible and wrong.
2805
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
2806
+ const rec = blockRecOf(liveBlockEl);
2807
+ if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
2808
+
2809
+ const res = selectionLib
2810
+ ? selectionLib.resolveMembership(blockSelection, blocks, rec)
2811
+ : { mode: 'single', members: [rec] };
2812
+ if (res.mode !== 'batch') {
2813
+ // §3.3's second half: 「grip 在集合外 → 先把集合換成該單一 block 再作用」.
2814
+ // Done here rather than left to the post-operation collapse so a REFUSAL
2815
+ // also leaves the documented state behind, instead of a set somewhere
2816
+ // else in the document that the user's next keystroke would batch over.
2817
+ // No selection standing at all stays exactly as it was pre-S3.
2818
+ if (blockSelection) {
2819
+ setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
2820
+ }
2821
+ return { els: [liveBlockEl], recs: [rec], batch: false };
2822
+ }
2823
+ const members = res.members;
2824
+ // Task 1 carry 6: `spanIsContiguous([])` is TRUE — no members, no gaps — so
2825
+ // emptiness is checked separately and gets its own wording. Unreachable
2826
+ // through the menu today (resolveMembership() only answers 'batch' when the
2827
+ // grip block is itself a member), but the two states are genuinely
2828
+ // different and a single gate would report the wrong one if it ever is.
2829
+ if (!members.length) { refuseStructuralListEdit(BATCH_EMPTY_MESSAGE); return null; }
2830
+ // Task 1 carry 2: a gap is NOT only what a disjoint selection produces. A
2831
+ // no-line phantom can sit BETWEEN two real members — `- a\n- - b\n- c\n`
2832
+ // yields li{1,1} | phantom{2,1} | li{2,2} | li{3,3}, so selecting lines 1–2
2833
+ // (an entirely natural gesture) lands on indices 0 and 2. The batch cannot
2834
+ // be expressed as one contiguous index range, so it refuses rather than
2835
+ // writing a range it cannot honour.
2836
+ if (!selectionLib.spanIsContiguous(members, blocks)) {
2837
+ refuseStructuralListEdit(BATCH_GAP_MESSAGE);
2838
+ return null;
2839
+ }
2840
+ const els = [];
2841
+ for (let i = 0; i < members.length; i++) {
2842
+ const el = blockElById(members[i].id);
2843
+ if (!el) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
2844
+ // Belt to membersOf()'s own braces: selection.js excludes a block that
2845
+ // owns no line from every member set it builds, so this is unreachable —
2846
+ // but "another module filters it" is an argument about another file's
2847
+ // output, and this is the guard that stands between an inverted range and
2848
+ // a commit helper.
2849
+ if (blockOwnsNoLine(el)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
2850
+ els.push(el);
2851
+ }
2852
+ return { els: els, recs: members, batch: members.length > 1 };
2853
+ }
2854
+
2855
+ // §3.3's collapse: 「操作後集合塌縮為「操作結果所涵蓋的行區間」」. Declared
2856
+ // IMMEDIATELY before the render and never earlier — rerenderAll() consumes the
2857
+ // declaration at its very top, before its first failure exit, so one left
2858
+ // standing lands on some LATER, unrelated render (Task 5 carry 5).
2859
+ //
2860
+ // ONLY when a set was actually standing, and that guard is load-bearing:
2861
+ // without it an ordinary single-block ⠿ 建立副本 or 轉換成 on a document with
2862
+ // NO selection would CREATE one — collapseTo() resolves the declared range,
2863
+ // membersOf() finds the block, and setBlockSelection() tints it and pulls the
2864
+ // roving focus onto it. That is a selection the user never made, and it would
2865
+ // change the resting state of every pre-S3 gutter gesture. resolveGutterOperands()
2866
+ // has already applied §3.3's 「grip 在集合外 → 先把集合換成該單一 block」, so
2867
+ // `blockSelection` is non-null here exactly when the gesture was a set
2868
+ // operation. Leaving the declaration UNSET is the correct answer otherwise:
2869
+ // `undefined` means "keep whatever is standing", and nothing is.
2870
+ function declareCollapse(range) {
2871
+ if (blockSelection) declareSelectionRange(range);
2872
+ }
2873
+
2874
+ const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
2875
+ const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
2876
+ const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
2877
+ const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
2878
+ // S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
2879
+ //
2880
+ // Task 3 raised all of these with a single provisional constant
2881
+ // (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
2882
+ // owned the final wording. They are three different problems with three
2883
+ // OPPOSITE remedies, so one sentence for all of them sends two thirds of
2884
+ // the users to fix the wrong end of their own gesture:
2885
+ // * the SOURCE seam — the block cannot leave where it is, because the two
2886
+ // lists it stands between would join into one loose run. No destination
2887
+ // helps; the remedy is not to move it (or to move something else).
2888
+ // * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
2889
+ // is to aim somewhere else, which is the exact opposite advice.
2890
+ // * OUT OF RUN — a list item may only move among its own §3.8 siblings.
2891
+ // The remedy is to stay inside its own list.
2892
+ // Naming follows the BATCH_*_MESSAGE convention above. None of the three is
2893
+ // a substring of another, of §4.3's own gate message, or of
2894
+ // BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
2895
+ // refusal fired rather than that some banner mentioning 清單 appeared.
2896
+ const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
2897
+ const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
2898
+ const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
2899
+ // S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
2900
+ // of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
2901
+ // removal half of the move through clampIndents()'s `{ removed: true }`),
2902
+ // which answers every SOURCE-side orphan. What no removal-clamp can reach is
2903
+ // the INSERTION half: a BATCH whose last member is SHALLOWER than the block
2904
+ // it lands before leaves that block with no parent chain above it, at the
2905
+ // NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
2906
+ // indent array of length 2..6 and depth 0..3, every operand-set size and
2907
+ // every destination the run gate admits: 4067 drops, 1425 refused by Task
2908
+ // 4's predicate, 14 still unanchored after the clamp — every one of them a
2909
+ // multi-block set breaking at the block immediately after the landed set.
2910
+ // (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
2911
+ //
2912
+ // RE-WORDED with the narrowing, and the wording is the point: Task 4's
2913
+ // 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
2914
+ // was true while the capability was missing and is a lie now that it is not.
2915
+ // What is left is a DESTINATION objection with the same remedy
2916
+ // BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
2917
+ // end of the gesture is at fault. Still not a substring of any other refusal
2918
+ // in this file, which is what lets a test assert WHICH one fired.
2919
+ const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
2920
+ // 轉換成's own refusals are NOT declared here. They are per block TYPE, and
2921
+ // they are the same ruling §3.7 applies to a single block's ⠿ — so they live
2922
+ // with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
2923
+ // toggleGutterMenu(), where the menu-hiding call site can share them. The
2924
+ // four constants above are shape-level and belong to every batch operation.
2925
+
2926
+ function blockElById(id) {
2927
+ return document.querySelector('.ed-block[data-block-id="' + id + '"]');
2928
+ }
2929
+
2930
+ // §3.4 rule 3's batch anchor: 「多 block 操作的錨點 = 選取集合中最小的舊
2931
+ // indent」, not the first member. Deleting `{a(0), b(1)}` anchored on a(0) is
2932
+ // right, but anchored on the FIRST member of `{b(1), …}` the following
2933
+ // segment head's bound is computed against 1 instead of 0 and a same-segment
2934
+ // sibling lands at −1 (undefined in columns).
2935
+ //
2936
+ // ⚠ This value is the anchor for the blocks BELOW the operated set — the
2937
+ // bound applyIndentClamp() re-measures them against — and NOTHING else.
2938
+ // §3.4 rule 3 also cites a batch TAB whose first member is at its ceiling as
2939
+ // a failure of first-member anchoring, but that half was superseded by the
2940
+ // 2026-08-31 D1 review: how far the set itself may move is batchIndentDelta()
2941
+ // and is the minimum head-room across ALL members, so a set holding a member
2942
+ // at its ceiling is a whole-batch no-op regardless of what its shallowest
2943
+ // member could have done alone. Do not re-derive Tab's delta from here.
2944
+ function spanMinIndent(els) {
2945
+ let min = null;
2946
+ (els || []).forEach((el) => {
2947
+ const v = Number(el.getAttribute('data-indent')) || 0;
2948
+ if (min === null || v < min) min = v;
2949
+ });
2950
+ return min === null ? 0 : min;
2951
+ }
2952
+
2953
+ // The kinds a span holds, and the one shape no batch path can express.
2954
+ // A contiguous span of list items is rewritten through its RUN's
2955
+ // re-serialization; a contiguous span of non-list blocks is a plain line
2956
+ // splice. A span holding BOTH is neither: the run's survivors have to be
2957
+ // re-emitted at the same time as a line range outside the run is removed, and
2958
+ // the blank-line policy at the seam between them has no ruling in the spec.
2959
+ // Refused with its own banner rather than guessed at.
2960
+ function spanListKinds(els) {
2961
+ const kinds = els.map((el) => el.getAttribute('data-block-type'));
2962
+ return {
2963
+ kinds: kinds,
2964
+ allLi: kinds.length > 0 && kinds.every((k) => k === 'li'),
2965
+ anyLi: kinds.indexOf('li') !== -1,
2966
+ };
2967
+ }
2968
+
2969
+ // Every list batch is one run's problem: listRunOf() is the span the commit
2970
+ // re-serializes, and a member outside it would be rewritten by a range that
2971
+ // does not cover it. Contiguity in `blocks` does not imply one run — two
2972
+ // adjacent runs separated by a delimiter change (`- a` / `* b`) are adjacent
2973
+ // blocks with no phantom between them.
2974
+ function batchRunOf(liEls) {
2975
+ const run = listRunOf(liEls[0]);
2976
+ if (!run.length) return null;
2977
+ for (let i = 0; i < liEls.length; i++) if (run.indexOf(liEls[i]) === -1) return null;
2978
+ return run;
2979
+ }
2980
+
2981
+ async function convertBlockViaMenu(blockEl, target) {
2982
+ if (!blockEl || !target) return;
2983
+ // §3.3's membership rules and the whole shared preamble — see
2984
+ // resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
2985
+ // central constraint is that every path below takes that span whole rather
2986
+ // than looping, because a loop re-renders between items and invalidates
2987
+ // every id in between (the defect class recorded in this file twice: a
2988
+ // fenced block raw-edited into two paragraphs changes the BLOCK count
2989
+ // without changing the LINE count, so an id-indexed delete hit the wrong
2990
+ // block, and the same shape silently rewrote a neighbouring table).
2991
+ const operands = await resolveGutterOperands(blockEl);
2992
+ if (!operands) return;
2993
+ const els = operands.els;
2994
+ const recs = operands.recs;
2995
+ const shape = spanListKinds(els);
2996
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
2997
+ // §3.7 / §7 withhold 轉換成 from some block types ENTIRELY —
2998
+ // toggleGutterMenu() hides the item outright, and the list plus every
2999
+ // reason is in BATCH_CONVERT_WITHHELD_MESSAGES above it. This is the SAME
3000
+ // predicate, asked of a SET: none of those reasons stops applying because
3001
+ // the block happens to be one member of a span, and a batch whose grip is a
3002
+ // paragraph reaches the withheld member anyway. MEASURED, before this gate
3003
+ // existed: a table span wrote '> | A | B |' … / '- | A | B |' with its other
3004
+ // rows as continuations, an hr span wrote '- ---', an html span wrote
3005
+ // '> <div>x</div>'. Nothing is LOST in any of them (they all still lex as
3006
+ // what they were) — which is exactly why it is so quiet: the single-block ⠿
3007
+ // and the batch ⠿ give OPPOSITE answers to the same question with nothing
3008
+ // on screen saying so, the shape §3.6's 2026-08-31 「不得另寫一條」 ruling
3009
+ // exists to prevent. ⇒ REFUSE, with a banner (§3.6: 靜默不動作是缺陷), file
3010
+ // byte-identical.
3011
+ //
3012
+ // The banner names the type that is ACTUALLY in the span, not one of the
3013
+ // four shape-level messages above: BATCH_MIXED names list items vs other
3014
+ // blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
3015
+ // names non-contiguity — either would name something the user's selection
3016
+ // has not done. So would telling a user with an hr in the selection that it
3017
+ // contains a table.
3018
+ //
3019
+ // SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
3020
+ // holding any of these types are measured correct and have no equivalent
3021
+ // objection — they do not need a target that can carry cells, or a marker
3022
+ // to strip — so the predicate deliberately does NOT reach
3023
+ // resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
3024
+ // those five cells as APPLYING so a widening shows up as a failure too.
3025
+ // Placed BELOW the mixed gate so no span that already refuses changes which
3026
+ // banner it gets.
3027
+ const withheld = shape.kinds.filter(convertWithheldFor)[0];
3028
+ if (withheld) {
3029
+ refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
3030
+ return;
2188
3031
  }
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
3032
 
2198
3033
  // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
2199
3034
  // listRunSupportsStructuralEdit() BEFORE any mutation, the same door
2200
3035
  // Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
2201
3036
  // is exactly what listRunOf() returns (the outermost run PLUS every
2202
- // descendant of its members) — see deleteListItemViaGutter()'s own note.
3037
+ // descendant of its members) — see deleteListItemsViaGutter()'s own note.
2203
3038
  //
2204
3039
  // 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
3040
+ // refusals below and, further down, of stripMarker(): a multi-line li must
3041
+ // report §4.1's 「此清單含不支援的格式,無法調整結構」 and not
3042
+ // convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
3043
+ // would get if stripMarker() saw it first (that function refuses a
2209
3044
  // 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.
3045
+ // 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
3046
+ // is what notices if this order is ever flipped.
2212
3047
  //
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.
3048
+ // A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's own
3049
+ // text or line count, so a multi-line li refuses as a TARGET while
3050
+ // remaining a perfectly good bystander. S3 Task 6 passes the WHOLE operand
3051
+ // set as the target list — every member is being rewritten, so every member
3052
+ // has to clear the gate, not just the one the ⠿ was pressed on.
2216
3053
  let liRun = null;
2217
- if (kind === 'li') {
2218
- liRun = listRunOf(liveBlockEl);
2219
- if (!liRun.length) return;
2220
- if (!listRunSupportsStructuralEdit(liRun, liveBlockEl)) { refuseStructuralListEdit(); return; }
3054
+ if (shape.allLi) {
3055
+ liRun = batchRunOf(els);
3056
+ if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
3057
+ if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
2221
3058
  }
2222
3059
 
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);
3060
+ // S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
3061
+ // the run stays a run and the existing re-serialization machinery applies
3062
+ // unchanged.
3063
+ if (shape.allLi && convertMd.targetIsList(target)) {
3064
+ await convertListItemsType(els, liRun, recs, target);
2227
3065
  return;
2228
3066
  }
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);
3067
+ // S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
3068
+ // so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
3069
+ // lines put between them — the plain path below would leave the converted
3070
+ // lines mid-list with no separator and lazy continuation would swallow them
3071
+ // into the item above (measured, §4.3 rule 1).
3072
+ if (shape.allLi) {
3073
+ await convertListItemsAway(els, liRun, recs, target);
2236
3074
  return;
2237
3075
  }
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.
3076
+ // S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
3077
+ // 2's looseness policy applies — eat the separator to an adjacent run of
3078
+ // the same list type, or the merged list goes LOOSE and every item of it
3079
+ // degrades read-only. Same rule, same helper the li → li path above uses.
2242
3080
  if (convertMd.targetIsList(target)) {
2243
- await convertBlockIntoList(liveBlockEl, rec, kind, target);
3081
+ await convertBlocksIntoList(els, recs, shape.kinds, target);
2244
3082
  return;
2245
3083
  }
2246
3084
 
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, {});
3085
+ // The plain path: N non-list blocks to a non-list target. One commit over
3086
+ // the span's whole line range, each member converted from its OWN source
3087
+ // lines (never re-serialized, which is what keeps a `~5px` a `~5px`) and
3088
+ // the pieces separated by a blank line so they re-lex as N blocks.
3089
+ const first = recs[0];
3090
+ const last = recs[recs.length - 1];
3091
+ const pieces = [];
3092
+ for (let i = 0; i < recs.length; i++) {
3093
+ const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
3094
+ const stripped = convertMd.stripMarker(src, shape.kinds[i]);
3095
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
3096
+ pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
3097
+ }
3098
+ const md = pieces.join('\n\n');
2251
3099
 
2252
3100
  const result = commitRangeEdit({ lines, blocks, stack },
2253
- rec.startLine, rec.endLine, newLines.join('\n'));
3101
+ first.startLine, last.endLine, md);
2254
3102
  // 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.
3103
+ // onto the undo stack either, so there is nothing to render or roll back,
3104
+ // and no selection range to declare.
2256
3105
  if (result.op === null) return;
2257
3106
  const prevLines = lines;
2258
3107
  lines = result.lines;
3108
+ // §3.3: 「操作後集合塌縮為操作結果所涵蓋的行區間」. Declared IMMEDIATELY
3109
+ // before the render, never earlier (Task 5 carry 5): rerenderAll() consumes
3110
+ // the declaration at its very top, so one left standing lands on some
3111
+ // later, unrelated render.
3112
+ declareCollapse({
3113
+ startLine: first.startLine,
3114
+ endLine: first.startLine + md.split('\n').length - 1,
3115
+ });
2259
3116
  const okRender = await safeRerenderAll();
2260
3117
  if (!okRender) {
2261
3118
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -2310,7 +3167,7 @@
2310
3167
  // them (list-md.js:462) and the WHOLE run degrades read-only with no
2311
3168
  // banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
2312
3169
  // conversion. A li therefore duplicates through its own RUN's
2313
- // re-serialization (duplicateListItem() below), which emits no blank at
3170
+ // re-serialization (duplicateListItems() below), which emits no blank at
2314
3171
  // all and re-runs §3.8's renumbering on the way.
2315
3172
  //
2316
3173
  // Neither path re-serializes the copy's CONTENT: the non-li path slices
@@ -2318,43 +3175,39 @@
2318
3175
  // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
2319
3176
  // for it and only re-states the marker. Both keep a `~5px` a `~5px`.
2320
3177
  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);
3178
+ // §3.3's membership rules plus the shared preamble — see
3179
+ // resolveGutterOperands(). The whole span is duplicated by ONE commit; a
3180
+ // per-member loop would re-render between items and invalidate every id in
3181
+ // between.
3182
+ const operands = await resolveGutterOperands(blockEl);
3183
+ if (!operands) return;
3184
+ const els = operands.els;
3185
+ const recs = operands.recs;
3186
+ const shape = spanListKinds(els);
3187
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
3188
+
3189
+ if (shape.allLi) {
3190
+ await duplicateListItems(els, recs);
2350
3191
  return;
2351
3192
  }
2352
3193
 
2353
- const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId,
2354
- lines.slice(rec.startLine - 1, rec.endLine));
3194
+ // The non-li span duplicates as ONE slice of `lines` — separators between
3195
+ // its members included, which is what makes the copies re-lex as the same N
3196
+ // blocks rather than one merged one. commitBlockInsertion() anchors on the
3197
+ // LAST member (it inserts BELOW its anchor), and its own leading blank is
3198
+ // the separator between the original span and the copy.
3199
+ const first = recs[0];
3200
+ const last = recs[recs.length - 1];
3201
+ const result = commitBlockInsertion({ lines, blocks, stack }, last.id,
3202
+ lines.slice(first.startLine - 1, last.endLine));
2355
3203
  if (result.op === null) return;
2356
3204
  const prevLines = lines;
2357
3205
  lines = result.lines;
3206
+ // §3.3's collapse. A duplicate inserts BELOW everything it copied, so the
3207
+ // originals keep the exact line range they had — declaring it is the same
3208
+ // answer as "keep what is standing" and says so explicitly, which is what
3209
+ // keeps this correct if the insertion point ever moves.
3210
+ declareCollapse({ startLine: first.startLine, endLine: last.endLine });
2358
3211
  if (!(await safeRerenderAll())) {
2359
3212
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2360
3213
  }
@@ -2366,16 +3219,18 @@
2366
3219
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
2367
3220
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
2368
3221
  // bravo').
2369
- async function duplicateListItem(liEl) {
2370
- const run = listRunOf(liEl);
2371
- if (!run.length) return;
3222
+ async function duplicateListItems(liEls, recs) {
3223
+ // Every list batch is one run's problem — see batchRunOf().
3224
+ const run = batchRunOf(liEls);
3225
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
2372
3226
  // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
2373
3227
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
2374
3228
  // which is exactly what listRunOf() returns (the outermost run PLUS every
2375
3229
  // descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
2376
3230
  // 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; }
3231
+ // TARGET while remaining a perfectly good bystander — and in a batch EVERY
3232
+ // member is a target.
3233
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
2379
3234
  // Captured BEFORE the copy enters the span. The copy carries the
2380
3235
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
2381
3236
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
@@ -2388,22 +3243,29 @@
2388
3243
  // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
2389
3244
  // following blocks at a STRICTLY greater indent — and listRunOf() already
2390
3245
  // 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;
3246
+ // For a BATCH the anchor is the LAST member's subtree, which is the direct
3247
+ // generalization: the copies land after everything the set owns, in the
3248
+ // set's own document order.
3249
+ const anchorLi = liEls[liEls.length - 1];
3250
+ const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
3251
+ const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
2393
3252
  const at = run.indexOf(lastEl);
2394
3253
  if (at < 0) return;
2395
3254
 
2396
- const copy = liEl.cloneNode(true);
3255
+ const copies = liEls.map((liEl) => liEl.cloneNode(true));
2397
3256
  // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
2398
3257
  // list token here" (§3.8 rule (d)) and serializeBlocks() resets the
2399
3258
  // ordinal counter on it. A copy is never a token boundary — it sits inside
2400
3259
  // the run it was cloned from — so a clone that kept the attribute would
2401
3260
  // restart the numbering: duplicating the first item of '1. alpha / 2.
2402
3261
  // 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));
3262
+ copies.forEach((copy) => copy.removeAttribute('data-list-start'));
3263
+ const span = run.slice(0, at + 1).concat(copies, run.slice(at + 1));
2405
3264
  mutateListRun(() => {
2406
- lastEl.parentNode.insertBefore(copy, lastEl.nextSibling);
3265
+ // One fixed reference node, so the copies land in their own order:
3266
+ // insertBefore(c1, ref) then insertBefore(c2, ref) gives lastEl, c1, c2.
3267
+ const ref = lastEl.nextSibling;
3268
+ copies.forEach((copy) => { lastEl.parentNode.insertBefore(copy, ref); });
2407
3269
  });
2408
3270
  // No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
2409
3271
  // DOM, the copy included. The map is what keeps both lines byte-identical
@@ -2422,6 +3284,15 @@
2422
3284
  // list-md.js re-states each line's marker from that element's OWN
2423
3285
  // attributes — which is the §3.8 renumbering, and which is also how the
2424
3286
  // copy keeps its 型態 / 縮排 / 勾選狀態.
3287
+ // §3.3's collapse, declared immediately before the render inside
3288
+ // commitListStructure(). The copies land AFTER everything the set owns and
3289
+ // no member's line COUNT can change (a multi-line target is refused above,
3290
+ // and §3.8's renumbering only ever changes marker WIDTH), so the originals
3291
+ // still occupy exactly the range they occupied before the commit.
3292
+ declareCollapse({
3293
+ startLine: recs[0].startLine,
3294
+ endLine: recs[recs.length - 1].endLine,
3295
+ });
2425
3296
  await commitListStructure(span, null, false,
2426
3297
  { presetRange: range, carryOver: bystanderCarryOver(span) });
2427
3298
  }
@@ -2430,64 +3301,45 @@
2430
3301
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
2431
3302
  // precondition as insertBlockBelow() above.
2432
3303
  async function deleteBlockViaGutter(blockEl) {
2433
- if (!blockEl) return;
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);
2452
- const ok = await switchAwayFrom();
2453
- if (!ok) return;
2454
- let liveBlockEl = blockEl;
2455
- if (!document.body.contains(blockEl)) {
2456
- liveBlockEl = reresolveBlockEl(identity) ||
2457
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2458
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2459
- }
2460
- const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2461
- const block = blocks.find((b) => b.id === liveBlockId);
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; }
3304
+ // §3.3's membership rules plus the shared preamble (identity capture,
3305
+ // switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
3306
+ // no-source-line refusal) — see resolveGutterOperands(). The stake on the
3307
+ // re-resolve is highest here: an id shift used to make this delete a
3308
+ // DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
3309
+ // block that survived.
3310
+ const operands = await resolveGutterOperands(blockEl);
3311
+ if (!operands) return;
3312
+ const els = operands.els;
3313
+ const recs = operands.recs;
3314
+ const shape = spanListKinds(els);
3315
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
2480
3316
  // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
2481
3317
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
2482
3318
  // 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);
3319
+ // deleteListItemsViaGutter() for the measurements and the routing.
3320
+ if (shape.allLi) {
3321
+ await deleteListItemsViaGutter(els);
2486
3322
  return;
2487
3323
  }
2488
- const result = commitListBlockRemoval({ lines, blocks, stack }, liveBlockId);
3324
+ // A contiguous span of non-list blocks is one line range: the separators
3325
+ // between its members are inside it by construction (members are adjacent
3326
+ // in `blocks`, so nothing else lives between them), and commitRangeRemoval()
3327
+ // absorbs exactly one adjacent blank on the outside — the same blank-line
3328
+ // contract commitListBlockRemoval() documents, which is literally this call
3329
+ // for a single block. The one-block case keeps going through that wrapper so
3330
+ // the shipped single-block path is byte-for-byte the S2 one.
3331
+ const first = recs[0];
3332
+ const last = recs[recs.length - 1];
3333
+ const result = els.length === 1
3334
+ ? commitListBlockRemoval({ lines, blocks, stack }, first.id)
3335
+ : commitRangeRemoval({ lines, blocks, stack }, first.startLine, last.endLine);
2489
3336
  const prevLines = lines;
2490
3337
  lines = result.lines;
3338
+ // §3.3's collapse: a delete's result covers NO lines, so the set is cleared
3339
+ // rather than collapsed (Task 1 carry 5 — collapseTo() answers null for an
3340
+ // inverted range, and §4.4 says a range that no longer resolves clears).
3341
+ // Declared immediately before the render, never earlier (Task 5 carry 5).
3342
+ declareCollapse(null);
2491
3343
  const okRender = await safeRerenderAll();
2492
3344
  if (!okRender) {
2493
3345
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -2522,31 +3374,44 @@
2522
3374
  // commit the re-serialized survivors over that range with every one of them
2523
3375
  // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
2524
3376
  // its marker and its leading columns.
2525
- async function deleteListItemViaGutter(liEl) {
2526
- const run = listRunOf(liEl);
2527
- if (!run.length) return;
3377
+ async function deleteListItemsViaGutter(liEls) {
3378
+ // Every list batch is one run's problem — see batchRunOf().
3379
+ const run = batchRunOf(liEls);
3380
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
2528
3381
  // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
2529
3382
  // exactly what listRunOf() returns (the outermost run PLUS every
2530
3383
  // descendant of its members), so the deeper runs this delete is about to
2531
3384
  // re-indent are covered, not just the target's own. Deleting is NOT
2532
3385
  // 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; }
3386
+ // target refuses per §4.1 — and in a batch every member is a target.
3387
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
2535
3388
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2536
3389
  if (!range) return;
2537
- const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2538
- const survivors = run.filter((el) => el !== liEl);
3390
+ // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
3391
+ // first member's — see spanMinIndent().
3392
+ const oldIndent = spanMinIndent(liEls);
3393
+ const survivors = run.filter((el) => liEls.indexOf(el) === -1);
2539
3394
  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);
3395
+ // Clamp FIRST, while every member is still in the span: `{ removed: true }`
3396
+ // is what tells the pure function that these blocks can no longer anchor
3397
+ // anything, and rule 2's scope is measured from the LAST of their
3398
+ // positions. ONE call for the whole set, not one per member: clampIndents()
3399
+ // takes an array of operated indices precisely so a batch computes one
3400
+ // segment delta per §3.4 rule 3 instead of N independent clamps, which is
3401
+ // what splits the user's siblings apart.
3402
+ applyIndentClamp(run, liEls, oldIndent, { removed: true });
3403
+ liEls.forEach((el) => removeListItem(el));
2545
3404
  });
2546
3405
  // No `mutatedEl`: the deleted block is not among the survivors, and every
2547
3406
  // survivor's own bytes are exactly what the file already holds. The marker
2548
3407
  // is re-stated by the serializer regardless of the carry-over, which is
2549
3408
  // what renumbers the run (§3.8) and applies the clamped indent.
3409
+ //
3410
+ // §3.3's collapse: a delete's result covers no lines, so the set is cleared.
3411
+ // Declared immediately before commitListStructure(), whose only render on
3412
+ // this path is the one it makes after the commit — the `!range` bail above
3413
+ // it cannot fire, `presetRange` is non-null by construction here.
3414
+ declareCollapse(null);
2550
3415
  await commitListStructure(survivors, null, false,
2551
3416
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
2552
3417
  }
@@ -2604,7 +3469,7 @@
2604
3469
  // BETWEEN two runs, to neither. So the commit range has to be widened
2605
3470
  // past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
2606
3471
  // 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.
3472
+ // §4.3 rule 1's edge blanks in convertListItemsAway() above.
2608
3473
  //
2609
3474
  // 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
2610
3475
  // into a healthy one freezes the healthy one too — and declining to merge
@@ -2684,18 +3549,18 @@
2684
3549
  return { startLine: startLine, endLine: endLine, ok: true };
2685
3550
  }
2686
3551
 
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) {
3552
+ // The {listType, indent} a span member will carry once the operand set
3553
+ // `liEls` has become `attrs`. Everything outside the set keeps what it has.
3554
+ function postConvertLiAttrs(el, liEls, attrs) {
2690
3555
  return {
2691
- listType: el === liEl
3556
+ listType: liEls.indexOf(el) !== -1
2692
3557
  ? attrs.listType
2693
3558
  : (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
2694
3559
  indent: Number(el.getAttribute('data-indent')) || 0,
2695
3560
  };
2696
3561
  }
2697
3562
 
2698
- async function convertListItemType(liEl, run, target) {
3563
+ async function convertListItemsType(liEls, run, recs, target) {
2699
3564
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2700
3565
  if (!range) return;
2701
3566
  const attrs = convertMd.listAttrsFor(target);
@@ -2719,22 +3584,30 @@
2719
3584
  if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
2720
3585
  });
2721
3586
  const merged = widenRangeForListMerge(range, run,
2722
- postConvertLiAttrs(headEl, liEl, attrs), postConvertLiAttrs(tailEl, liEl, attrs));
3587
+ postConvertLiAttrs(headEl, liEls, attrs), postConvertLiAttrs(tailEl, liEls, attrs));
2723
3588
  if (!merged.ok) { refuseStructuralListEdit(); return; }
3589
+ // How far the run's own first line MOVES. widenRangeForListMerge() only ever
3590
+ // widens the range BACKWARDS over blank separators (§4.3 rule 2's tight
3591
+ // merge), and the markdown written over the widened range is the run's own
3592
+ // serialization with no leading blank — so every line of the run shifts up
3593
+ // by exactly this much, and the collapse range below has to shift with it.
3594
+ const shift = merged.startLine - range.startLine;
2724
3595
  range.startLine = merged.startLine;
2725
3596
  range.endLine = merged.endLine;
2726
3597
  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
- }
3598
+ liEls.forEach((liEl) => {
3599
+ liEl.setAttribute('data-list-type', attrs.listType);
3600
+ liEl.setAttribute('data-task', attrs.task ? '1' : '0');
3601
+ const box = liCheckEl(liEl);
3602
+ if (attrs.task) {
3603
+ // Insert BEFORE the surface: §4.1 fixes the child order as
3604
+ // marker → check → text, and list-md.js's firstChildWithClass() plus
3605
+ // the delegated checkbox-toggle listener both assume it.
3606
+ if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
3607
+ } else if (box) {
3608
+ box.remove();
3609
+ }
3610
+ });
2738
3611
  });
2739
3612
  // NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
2740
3613
  // sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
@@ -2750,6 +3623,15 @@
2750
3623
  // a carried line, i.e. it re-states the marker from the NEW attributes,
2751
3624
  // and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
2752
3625
  // carried source. That is what makes '- [x] alpha' → '- alpha' work.
3626
+ //
3627
+ // §3.3's collapse: the operand set stays exactly where it is — the members
3628
+ // are still list items in the same run and only their MARKERS are re-stated,
3629
+ // so no line count inside the run can change. Only the merge widening moves
3630
+ // them, by `shift`.
3631
+ declareCollapse({
3632
+ startLine: recs[0].startLine + shift,
3633
+ endLine: recs[recs.length - 1].endLine + shift,
3634
+ });
2753
3635
  await commitListStructure(run, null, false,
2754
3636
  { presetRange: range, carryOver: bystanderCarryOver(run) });
2755
3637
  }
@@ -2788,17 +3670,39 @@
2788
3670
  // run-wide veto and re-checked against the TARGET's line range only).
2789
3671
  // Measured; the 'a multi-line bystander is replayed, not refused' scenario
2790
3672
  // is what notices.
2791
- async function convertListItemAway(liEl, run, rec, target) {
3673
+ async function convertListItemsAway(liEls, run, recs, target) {
2792
3674
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2793
3675
  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);
3676
+ // Each member's own bytes, read from `lines` and never re-serialized — that
3677
+ // is what keeps a `~5px` a `~5px`. Joined by a blank line so N converted
3678
+ // items re-lex as N blocks and not one lazy continuation of the first.
3679
+ const convertedPieces = [];
3680
+ for (let i = 0; i < liEls.length; i++) {
3681
+ const rec = recs[i];
3682
+ const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
3683
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
3684
+ convertedPieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
3685
+ }
3686
+ const convertedMd = convertedPieces.join('\n\n');
3687
+
3688
+ // §3.4 rule 3's batch anchor — the SMALLEST old indent in the set.
3689
+ const oldIndent = spanMinIndent(liEls);
3690
+ // ONE contiguous index range, never a loop over `run.indexOf(member)`
3691
+ // per item: the run is split ONCE into the survivors before the set and the
3692
+ // survivors after it. The operand set is contiguous in `blocks` (checked by
3693
+ // spanIsContiguous() before any of this) and the run is a contiguous slice
3694
+ // of the same list, so the two indices below bracket exactly `liEls.length`
3695
+ // members — asserted rather than assumed, because this arithmetic is what
3696
+ // writes the bytes and an off-by-one here silently re-serializes a
3697
+ // bystander into the converted half.
3698
+ const idx = run.indexOf(liEls[0]);
3699
+ const lastIdx = run.indexOf(liEls[liEls.length - 1]);
3700
+ if (idx < 0 || lastIdx < idx || lastIdx - idx + 1 !== liEls.length) {
3701
+ refuseStructuralListEdit(BATCH_GAP_MESSAGE);
3702
+ return;
3703
+ }
2800
3704
  const before = run.slice(0, idx);
2801
- const after = run.slice(idx + 1);
3705
+ const after = run.slice(lastIdx + 1);
2802
3706
 
2803
3707
  // §3.4, and the FIRST production caller of the pure clamp's
2804
3708
  // `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
@@ -2824,17 +3728,21 @@
2824
3728
  // The 'the §3.4 segment deltas survive the split commit' scenario is that
2825
3729
  // shape, and it is the one that goes red without this option.
2826
3730
  mutateListRun(() => {
2827
- applyIndentClamp(run, liEl, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
3731
+ applyIndentClamp(run, liEls, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
2828
3732
  });
2829
3733
 
2830
3734
  // No `mutatedEl`: the converted block is in neither half, and every
2831
3735
  // survivor's bytes are exactly what the file already holds. Naming a block
2832
3736
  // here EXCLUDES it from the replay map, which is what sends its content
2833
- // back through escapeText() — see convertListItemType()'s note.
3737
+ // back through escapeText() — see convertListItemsType()'s note.
2834
3738
  const carry = bystanderCarryOver(before.concat(after));
2835
3739
  const pieces = [];
2836
3740
  if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
2837
- pieces.push(convertedLines.join('\n'));
3741
+ // Which piece the converted blocks are — the collapse range below counts
3742
+ // emitted LINES up to it, and "0 or 1" is only true while `before` is the
3743
+ // one optional piece ahead of it.
3744
+ const convertedPieceIdx = pieces.length;
3745
+ pieces.push(convertedMd);
2838
3746
  if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
2839
3747
  let md = pieces.join('\n\n');
2840
3748
 
@@ -2845,15 +3753,27 @@
2845
3753
  // 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
2846
3754
  // added only when the neighbour is not already blank, which is also what
2847
3755
  // 「正規化連續空行」 amounts to here: no double separator is ever created.
3756
+ let lead = 0;
2848
3757
  if (!before.length && range.startLine > 1 &&
2849
- String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
3758
+ String(lines[range.startLine - 2]).trim() !== '') { md = '\n' + md; lead = 1; }
2850
3759
  if (!after.length && range.endLine < lines.length &&
2851
3760
  String(lines[range.endLine]).trim() !== '') md = md + '\n';
2852
3761
 
3762
+ // §3.3's collapse: 「操作後集合塌縮為操作結果所涵蓋的行區間」— the CONVERTED
3763
+ // blocks' own lines, not the whole commit range (which is the entire run,
3764
+ // bystanders included). Counted in emitted lines: every '\n\n' join
3765
+ // between two pieces adds one blank line on top of the piece's own lines,
3766
+ // and `lead` is the one §4.3 rule 1 puts at the very front.
3767
+ let offset = lead;
3768
+ for (let k = 0; k < convertedPieceIdx; k++) offset += pieces[k].split('\n').length + 1;
3769
+ const outStart = range.startLine + offset;
3770
+ const outEnd = outStart + convertedMd.split('\n').length - 1;
3771
+
2853
3772
  const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
2854
3773
  if (result.op === null) return;
2855
3774
  const prevLines = lines;
2856
3775
  lines = result.lines;
3776
+ declareCollapse({ startLine: outStart, endLine: outEnd });
2857
3777
  if (!(await safeRerenderAll())) {
2858
3778
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2859
3779
  }
@@ -2873,26 +3793,44 @@
2873
3793
  // to no run. The gate that DOES apply is the one inside
2874
3794
  // widenRangeForListMerge(), on whichever neighbouring run this block is
2875
3795
  // about to merge into.
2876
- async function convertBlockIntoList(blockEl, rec, kind, target) {
3796
+ async function convertBlocksIntoList(blockEls, recs, kinds, target) {
2877
3797
  const attrs = convertMd.listAttrsFor(target);
2878
3798
  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, {});
3799
+ // One item per member, joined by a bare newline: the members' own
3800
+ // separators are INSIDE the commit range and are replaced, which is what
3801
+ // makes N paragraphs one TIGHT list. A blank between them would make the
3802
+ // list loose and degrade every item read-only (§4.3 rule 2).
3803
+ const pieces = [];
3804
+ for (let i = 0; i < recs.length; i++) {
3805
+ const stripped = convertMd.stripMarker(
3806
+ lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
3807
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
3808
+ pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
3809
+ }
3810
+ const md = pieces.join('\n');
3811
+ const first = recs[0];
3812
+ const last = recs[recs.length - 1];
2882
3813
 
2883
3814
  // 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.
3815
+ // span's post-conversion identity is (target list type, indent 0) on both
3816
+ // edges — it emits one item per member, however many physical lines each
3817
+ // item spans.
2886
3818
  const self = { listType: attrs.listType, indent: 0 };
2887
3819
  const merged = widenRangeForListMerge(
2888
- { startLine: rec.startLine, endLine: rec.endLine }, [blockEl], self, self);
3820
+ { startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
2889
3821
  if (!merged.ok) { refuseStructuralListEdit(); return; }
2890
3822
 
2891
3823
  const result = commitRangeEdit({ lines, blocks, stack },
2892
- merged.startLine, merged.endLine, newLines.join('\n'));
3824
+ merged.startLine, merged.endLine, md);
2893
3825
  if (result.op === null) return;
2894
3826
  const prevLines = lines;
2895
3827
  lines = result.lines;
3828
+ // §3.3's collapse: the emitted items occupy the whole (possibly widened)
3829
+ // commit range — nothing else is written here.
3830
+ declareCollapse({
3831
+ startLine: merged.startLine,
3832
+ endLine: merged.startLine + md.split('\n').length - 1,
3833
+ });
2896
3834
  if (!(await safeRerenderAll())) {
2897
3835
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2898
3836
  }
@@ -3062,6 +4000,10 @@
3062
4000
  hideTableGrips();
3063
4001
  hideTableEdgeMenu();
3064
4002
  cancelTeDrag();
4003
+ // S4 Task 2: and an in-flight ⠿ block drag, for the same reason — this
4004
+ // resolution is about to commit and re-render whichever block it holds an
4005
+ // ordinal into.
4006
+ cancelBlockDrag();
3065
4007
  if (!burst) return true;
3066
4008
  // §10-gap fix (review): this burst's block's "pristine" window (see
3067
4009
  // `pristineInsert`'s own comment) closes right here, the moment its
@@ -3308,6 +4250,408 @@
3308
4250
  return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
3309
4251
  }
3310
4252
 
4253
+ // ── S3 Task 2: block multi-select state ────────────────────────────────
4254
+ // The selection's identity is a LINE RANGE, never ids and never nodes.
4255
+ // buildBlockMap renumbers every id from 0 on every render (blockmap.js's
4256
+ // `nextId = {v:0}`) and every batch operation triggers a full
4257
+ // rerenderAll(), so an id or an element held across a commit is a dangling
4258
+ // reference into a document that no longer exists — the same reasoning
4259
+ // blockElAtLine() above is written down for. All the range arithmetic and
4260
+ // §3.3's membership rules live in the pure, node-tested
4261
+ // lib/editor/selection.js (window.md2docSelection); this file only paints.
4262
+ let blockSelection = null; // { anchorLine, focusLine } | null
4263
+
4264
+ // §4.4: the focus endpoint's block element. Null when the selection's focus
4265
+ // line is not (or is no longer) some block's startLine — the caller then
4266
+ // simply holds no focus rather than guessing at a neighbour.
4267
+ function selectionFocusBlockEl() {
4268
+ if (!blockSelection) return null;
4269
+ return blockElAtLine(blockSelection.focusLine);
4270
+ }
4271
+
4272
+ // Repaints `.ed-selected` and the roving tabindex from `blockSelection`.
4273
+ // Idempotent and total: it clears both attributes off EVERY block first, so
4274
+ // it is equally the "apply" and the "clear" path and no stale tint can
4275
+ // survive a state change.
4276
+ function applySelectionClasses() {
4277
+ const members = blockSelection && selectionLib
4278
+ ? selectionLib.membersOf(blockSelection, blocks) : [];
4279
+ const ids = new Set(members.map((b) => String(b.id)));
4280
+ for (const el of allBlockEls()) {
4281
+ el.classList.toggle('ed-selected', ids.has(el.getAttribute('data-block-id')));
4282
+ el.removeAttribute('tabindex');
4283
+ }
4284
+ // §4.4 wants a REAL focus holder: with focus left on <body> the keydown
4285
+ // dispatch has nothing to anchor on and the browser's own Tab order walks
4286
+ // straight past the selection. A ROVING tabindex="-1" (exactly one block
4287
+ // focusable at a time, moved as the focus endpoint moves) is the standard
4288
+ // answer, and -1 rather than 0 keeps every block out of the sequential Tab
4289
+ // order — Tab inside the editor is already a structural key. There is no
4290
+ // other tabindex anywhere in lib/; this is greenfield.
4291
+ //
4292
+ // Focusing a .ed-block is inert for the burst machinery: the delegated
4293
+ // focusin handler bails unless the target closes onto .ed-wys-cell or
4294
+ // .ed-wys-armed, and the block WRAPPER is neither (armEditables() arms
4295
+ // blockContentEl(), a child).
4296
+ const focusEl = selectionFocusBlockEl();
4297
+ if (focusEl) { focusEl.setAttribute('tabindex', '-1'); focusEl.focus(); }
4298
+ }
4299
+
4300
+ // S3 Task 4: the keyboard's anchor for §4.4 entry (c) once the set is gone.
4301
+ // MEASURED on 2026-08-30, contradicting Task 2 carry 7 / Task 3 carry 7:
4302
+ // removing the roving `tabindex` from the block that currently has DOM
4303
+ // focus BLURS it in Chromium (`document.activeElement` becomes <body>), so
4304
+ // applySelectionClasses()'s "clear both attributes off EVERY block first"
4305
+ // means a cleared selection leaves NO focused block behind. Without this
4306
+ // memory there is nothing for Shift+↑↓ to resume from after an Escape: the
4307
+ // caret's own surface owns those keys (they are the browser's
4308
+ // extend-the-text-selection gesture, and the burst short-circuit keeps them
4309
+ // that way), and <body> has no block to anchor on.
4310
+ let lastSelectionFocusLine = null;
4311
+
4312
+ function setBlockSelection(sel) {
4313
+ if (sel && Number.isFinite(Number(sel.focusLine))) lastSelectionFocusLine = Number(sel.focusLine);
4314
+ blockSelection = sel;
4315
+ applySelectionClasses();
4316
+ }
4317
+ function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
4318
+
4319
+ // ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
4320
+ // `contentEl.innerHTML` is replaced wholesale on every commit, so the
4321
+ // painted tint and the roving focus holder are destroyed by definition —
4322
+ // MEASURED on this branch: with a selection over lines 3–4 standing, a
4323
+ // rerenderAll() leaves `blockSelection` intact (its identity is a line
4324
+ // range, which no render can invalidate) but the fresh server HTML carries
4325
+ // no `.ed-selected` and no `tabindex`, so `document.activeElement` is
4326
+ // `<body>` and the keyboard is dead while the model still says two blocks
4327
+ // are selected. The model and the paint drift apart, and every batch
4328
+ // operation in Tasks 6/7 ends in exactly this swap.
4329
+ //
4330
+ // NOTE for the plan's own wording: it says "without the rebuild the tint
4331
+ // survives (it is re-derived from lines) but focus falls back to <body>".
4332
+ // Only the second half is true. Nothing re-derives the tint — the swap
4333
+ // simply throws the classes away with the nodes that carried them.
4334
+ //
4335
+ // §4.4 also says each structural operation DECLARES the line range it
4336
+ // produced, and that a range which no longer resolves clears the selection
4337
+ // rather than leaving it dangling. `pendingSelectionRange` is that
4338
+ // declaration: `undefined` means "nothing declared, keep whatever is
4339
+ // standing" (a burst commit must not destroy a selection it never touched),
4340
+ // `null` means "clear" (undo/redo), and a `{startLine, endLine}` means
4341
+ // "collapse to this" (Tasks 6/7's batch operations).
4342
+ let pendingSelectionRange;
4343
+ function declareSelectionRange(range) { pendingSelectionRange = range; }
4344
+
4345
+ // §4.4 step 3: the rebuilt set must have a REAL focus holder, not <body>.
4346
+ // A declared range's focus endpoint is a LINE, and after a render that line
4347
+ // is often the INSIDE of a block rather than its startLine — collapseTo()
4348
+ // hands back the range's endLine, and a table or a fence owns four lines
4349
+ // for one block. selectionFocusBlockEl() answers null for such a line
4350
+ // (Task 2 carry 5), i.e. a selection with no focus holder and a dead
4351
+ // keyboard, which is the whole failure this task exists to prevent. So the
4352
+ // focus endpoint is snapped onto the startLine of the member it lands in,
4353
+ // on the side it was already on so the gesture stays reversible.
4354
+ //
4355
+ // The snap is REFUSED if it would change the member set: Tasks 6/7 compute
4356
+ // the batch anchor from these members and that anchor writes bytes, so a
4357
+ // focus holder is not worth a silently different set. That branch is
4358
+ // defensive — snapping only ever shrinks the range towards the anchor, and
4359
+ // a member that would drop out would have to start after the block the
4360
+ // focus line lands in — but "buildBlockMap never emits that" is an argument
4361
+ // about another file's output, not an invariant this one can enforce.
4362
+ function selectionWithFocusHolder(sel, members) {
4363
+ if (blockElAtLine(sel.focusLine)) return sel;
4364
+ const snapTo = Number(sel.focusLine) < Number(sel.anchorLine)
4365
+ ? members[0] : members[members.length - 1];
4366
+ if (!snapTo) return sel;
4367
+ const candidate = { anchorLine: sel.anchorLine, focusLine: snapTo.startLine };
4368
+ const after = selectionLib.membersOf(candidate, blocks);
4369
+ if (after.length !== members.length) return sel;
4370
+ for (let i = 0; i < after.length; i++) if (after[i] !== members[i]) return sel;
4371
+ return blockElAtLine(candidate.focusLine) ? candidate : sel;
4372
+ }
4373
+
4374
+ // §4.4 step 2, called from rerenderAll() AFTER its unconditional teardown
4375
+ // (so nothing below can null what this just set) and BEFORE the two
4376
+ // `try`-swallowed rebind blocks (so a diagram-init throw cannot skip it).
4377
+ // `armEditables()` still runs first, per its own comment.
4378
+ function rebuildBlockSelection(declaredRange) {
4379
+ // Nothing standing and nothing declared: the fresh DOM already carries no
4380
+ // tint and no tabindex, so there is nothing to repaint and no reason to
4381
+ // walk every block on a render that has no selection anywhere near it.
4382
+ if (declaredRange === undefined && !blockSelection) return;
4383
+ const sel = declaredRange === undefined
4384
+ ? blockSelection
4385
+ : (selectionLib ? selectionLib.collapseTo(declaredRange) : null);
4386
+ if (!sel || !selectionLib) { clearBlockSelection(); return; }
4387
+ const members = selectionLib.membersOf(sel, blocks);
4388
+ if (!members.length) { clearBlockSelection(); return; }
4389
+ setBlockSelection(selectionWithFocusHolder(sel, members));
4390
+ }
4391
+
4392
+ // The block record behind a rendered `.ed-block`, or null. Every gesture
4393
+ // below turns an element (or a point) into a LINE this way — the line is
4394
+ // the selection's identity, the id is only how the DOM addresses it in
4395
+ // between two renders.
4396
+ function blockRecOf(blockEl) {
4397
+ if (!blockEl || !blockEl.getAttribute) return null;
4398
+ const raw = blockEl.getAttribute('data-block-id');
4399
+ if (raw === null) return null; // provisional block: no record yet
4400
+ return blocks.find((b) => b.id === Number(raw)) || null;
4401
+ }
4402
+
4403
+ // Test-only hooks. Task 2 shipped the two WRITE hooks (no gesture created a
4404
+ // selection yet); Task 4 adds the READ one so a gesture scenario can assert
4405
+ // the resulting member set BY LINE RANGE rather than by counting tinted
4406
+ // nodes — a test that counts `.ed-selected` silently passes or fails on
4407
+ // Task 2's CSS instead of on the gesture under test. `memberLines` is the
4408
+ // model's own answer (selection.js against `blocks`); `domSelectedLines` is
4409
+ // the same question asked of the DOM, so a scenario can pin both and catch
4410
+ // the two drifting apart. They are the browser-side counterpart of the
4411
+ // node-side `module.exports` guard at the top of this file — the same
4412
+ // "expose the seam the tests need, in the one environment that has it"
4413
+ // split — and nothing in the product calls them.
4414
+ window.__edTestSetSelection = function (anchorLine, focusLine) {
4415
+ setBlockSelection({ anchorLine: anchorLine, focusLine: focusLine });
4416
+ };
4417
+ window.__edTestClearSelection = function () { clearBlockSelection(); };
4418
+ window.__edTestGetSelection = function () {
4419
+ if (!blockSelection) return null;
4420
+ const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
4421
+ return {
4422
+ anchorLine: blockSelection.anchorLine,
4423
+ focusLine: blockSelection.focusLine,
4424
+ memberLines: members.map((b) => [b.startLine, b.endLine]),
4425
+ domSelectedLines: allBlockEls()
4426
+ .filter((el) => el.classList.contains('ed-selected'))
4427
+ .map((el) => { const r = blockRecOf(el); return r ? [r.startLine, r.endLine] : null; }),
4428
+ focusHolderId: (function () {
4429
+ const el = selectionFocusBlockEl();
4430
+ return el ? el.getAttribute('data-block-id') : null;
4431
+ })(),
4432
+ };
4433
+ };
4434
+ // Task 5 seams. `__edTestForceRerender` runs the REAL rerenderAll() (not
4435
+ // safeRerenderAll(), so a scenario sees a throw rather than a banner) —
4436
+ // every batch operation in Tasks 6/7 ends in one, and this is how a
4437
+ // scenario exercises the swap without also exercising a batch operation
4438
+ // that does not exist yet. `__edTestTruncateTo` drops `lines` to its first
4439
+ // n, which is how a scenario reaches the "the selection's lines no longer
4440
+ // parse" state without an operation that deletes blocks.
4441
+ // Task 6 seam: the raw `blocks` records. A scenario needs them to prove the
4442
+ // shape of its OWN fixture — specifically that a no-line PHANTOM really does
4443
+ // sit BETWEEN two members, which is an INVERTED {startLine, endLine} at a
4444
+ // known index. Nothing in the DOM carries that fact: the phantom's element is
4445
+ // there, its (missing) line range is not, so a gap scenario that asserted the
4446
+ // gap from the DOM alone would be asserting something it merely believes.
4447
+ window.__edTestBlocks = function () {
4448
+ return blocks.map((b) => ({
4449
+ id: b.id, type: b.type, startLine: b.startLine, endLine: b.endLine,
4450
+ }));
4451
+ };
4452
+ window.__edTestForceRerender = function () { return rerenderAll(); };
4453
+ window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
4454
+
4455
+ // ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
4456
+ // §4.4 entries: (a) press inside a block and drag across its boundary;
4457
+ // (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
4458
+ // and a click inside any block without Shift. Scrolling and window blur
4459
+ // deliberately do NOT clear — the drag they abort is torn down, the
4460
+ // selection it built stands.
4461
+ //
4462
+ // Everything below turns a POINT or an ELEMENT into a LINE and hands it to
4463
+ // selection.js; no gesture ever holds an id or a node across a repaint.
4464
+
4465
+ // Chrome that owns its own press/click semantics. A gesture must never be
4466
+ // armed on one of these, or Shift+Clicking the ⠿ handle would build a
4467
+ // selection instead of opening the menu, and a drag inside the raw
4468
+ // textarea would fight its own text selection.
4469
+ const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-insert-menu, ' +
4470
+ '.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-conflict, .ed-raw';
4471
+ function isSelGestureChrome(target) {
4472
+ return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
4473
+ }
4474
+
4475
+ // The line a gesture landing on `target` selects, or null when there is
4476
+ // none. Task 1 carry 3 / Task 2 carry 5: the focus endpoint must always be
4477
+ // the `startLine` of a block that OWNS a line — selectionFocusBlockEl()
4478
+ // returns null for anything else, which would leave a selection with no
4479
+ // focus holder and a dead keyboard. A phantom (the outer item of a
4480
+ // same-line nest, `endLine < startLine`) is therefore never the answer; its
4481
+ // own first line-owning descendant is, since that descendant is what the
4482
+ // user sees inside the phantom's box.
4483
+ function selectableLineOf(target) {
4484
+ let el = target && target.closest ? target.closest('.ed-block') : null;
4485
+ while (el) {
4486
+ const rec = blockRecOf(el);
4487
+ if (rec && rec.endLine >= rec.startLine) return rec.startLine;
4488
+ const inner = el.querySelector('.ed-block');
4489
+ const innerRec = inner ? blockRecOf(inner) : null;
4490
+ if (innerRec && innerRec.endLine >= innerRec.startLine) return innerRec.startLine;
4491
+ el = el.parentElement && el.parentElement.closest ? el.parentElement.closest('.ed-block') : null;
4492
+ }
4493
+ return null;
4494
+ }
4495
+
4496
+ function selectableLineAtPoint(clientX, clientY) {
4497
+ // Coordinates, not `e.target`: once a drag is under pointer capture every
4498
+ // pointer event retargets to the capture element, so the target says
4499
+ // nothing about what is under the cursor. elementFromPoint always does.
4500
+ const el = document.elementFromPoint(clientX, clientY);
4501
+ if (!el || !contentEl.contains(el)) return null;
4502
+ return selectableLineOf(el);
4503
+ }
4504
+
4505
+ function focusedSelectableLine() {
4506
+ const el = document.activeElement;
4507
+ return el && el.closest ? selectableLineOf(el) : null;
4508
+ }
4509
+
4510
+ function clearNativeTextSelection() {
4511
+ const s = window.getSelection();
4512
+ if (s && typeof s.removeAllRanges === 'function') s.removeAllRanges();
4513
+ }
4514
+
4515
+ // The in-flight press. `dragging` flips only once the pointer has crossed
4516
+ // into a DIFFERENT block — §4.4's entry threshold is that boundary, not a
4517
+ // pixel distance, so a press-and-wiggle inside one block stays ordinary
4518
+ // text selection.
4519
+ let blockSelDrag = null;
4520
+ // A gesture's own trailing `click`. A press in one block released in
4521
+ // another still fires one (on their common ancestor), and
4522
+ // wireBlockSelection()'s click handler would answer it with either
4523
+ // switchAwayFrom() (released outside any block) or the §4.4 exit rule
4524
+ // (released inside one) — the second of which would clear the very
4525
+ // selection the drag just built. Consumed exactly once, and re-armed to
4526
+ // false by the next pointerdown so a gesture whose click never arrives
4527
+ // (an abort, a release outside the window) cannot swallow a later one.
4528
+ let blockSelClickSuppressed = false;
4529
+
4530
+ function armBlockSelDrag(e) {
4531
+ if (isSelGestureChrome(e.target)) return;
4532
+ const line = selectableLineOf(e.target);
4533
+ if (line === null) return;
4534
+ blockSelDrag = {
4535
+ pointerId: e.pointerId,
4536
+ originLine: line,
4537
+ // §4.4's table exception: a drag whose origin is a cell and which never
4538
+ // leaves that table keeps native text selection. Measured: a table is
4539
+ // exactly ONE .ed-block, so the block-boundary rule already says the
4540
+ // same thing — this is kept explicit because it is the spec's own
4541
+ // wording and because it stays correct if a cell ever comes to contain
4542
+ // blocks of its own.
4543
+ originTableEl: e.target.closest ? e.target.closest('table') : null,
4544
+ focusLine: line,
4545
+ dragging: false,
4546
+ captureEl: null,
4547
+ };
4548
+ }
4549
+
4550
+ // Mirrors the table drag's capture skeleton (setPointerCapture at the top
4551
+ // of the te pointerdown, releaseTeCapture() on every exit): capture is what
4552
+ // keeps pointermove/up arriving once the cursor leaves the window. Taken at
4553
+ // ENGAGE time rather than at press time, so a press that turns out to be
4554
+ // plain text selection is never interfered with, and on documentElement
4555
+ // rather than the pressed node, which a mid-gesture commit can detach.
4556
+ function captureBlockSelDrag(e) {
4557
+ const el = document.documentElement;
4558
+ if (el && typeof el.setPointerCapture === 'function') {
4559
+ try { el.setPointerCapture(e.pointerId); blockSelDrag.captureEl = el; }
4560
+ catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
4561
+ }
4562
+ }
4563
+
4564
+ // Unconditional teardown of the in-flight press, called from pointerup,
4565
+ // pointercancel, the window blur listener, the next pointerdown, and the
4566
+ // "no buttons are down any more" guard in pointermove. The SELECTION is
4567
+ // never touched here: §4.4 says blur does not clear, and an aborted drag
4568
+ // must leave a complete set rather than a half-built one.
4569
+ function endBlockSelDrag() {
4570
+ if (!blockSelDrag) return;
4571
+ const st = blockSelDrag;
4572
+ blockSelDrag = null;
4573
+ if (st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
4574
+ try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
4575
+ }
4576
+ if (st.dragging) blockSelClickSuppressed = true;
4577
+ }
4578
+
4579
+ function updateBlockSelDrag(e) {
4580
+ if (!blockSelDrag || e.pointerId !== blockSelDrag.pointerId) return;
4581
+ if (tePointer) return; // a grip gesture owns this pointer
4582
+ // The browser can simply never deliver a pointerup (released over browser
4583
+ // chrome, over another window). The next move with no button held is the
4584
+ // only signal left that the gesture is over.
4585
+ if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockSelDrag(); return; }
4586
+ const overEl = document.elementFromPoint(e.clientX, e.clientY);
4587
+ if (!blockSelDrag.dragging) {
4588
+ if (blockSelDrag.originTableEl && overEl && blockSelDrag.originTableEl.contains(overEl)) return;
4589
+ const line = selectableLineAtPoint(e.clientX, e.clientY);
4590
+ if (line === null || line === blockSelDrag.originLine) return;
4591
+ blockSelDrag.dragging = true;
4592
+ blockSelDrag.focusLine = line;
4593
+ captureBlockSelDrag(e);
4594
+ } else {
4595
+ const line = selectableLineAtPoint(e.clientX, e.clientY);
4596
+ // Off every block (the page margin, an overlay): keep the last block the
4597
+ // drag actually reached rather than collapsing the set mid-gesture.
4598
+ if (line !== null) blockSelDrag.focusLine = line;
4599
+ }
4600
+ e.preventDefault();
4601
+ // The press started a native text selection that keeps extending with the
4602
+ // pointer; once the gesture is a BLOCK selection the two must not both be
4603
+ // painted. preventDefault() on pointermove does not stop it, so it is
4604
+ // dropped explicitly on every frame of the drag.
4605
+ clearNativeTextSelection();
4606
+ setBlockSelection({ anchorLine: blockSelDrag.originLine, focusLine: blockSelDrag.focusLine });
4607
+ }
4608
+
4609
+ // §4.4 entry (b). The anchor is the standing selection's own anchor, or —
4610
+ // entering fresh — the block that holds the caret, so Shift+Click reads as
4611
+ // "from where I am to here". With neither, it collapses onto the clicked
4612
+ // block, which is extendTo()'s own answer for a null selection.
4613
+ function beginShiftClickSelection(e) {
4614
+ if (isSelGestureChrome(e.target)) return;
4615
+ const line = selectableLineOf(e.target);
4616
+ if (line === null) return;
4617
+ const seed = focusedSelectableLine();
4618
+ // Shift+Click INSIDE the block that already holds the caret is the one
4619
+ // Shift+Click that must stay native: it is how a user extends a text
4620
+ // selection to a point, and there is no second block to take.
4621
+ if (!blockSelection && seed !== null && seed === line) return;
4622
+ // Cancels the caret placement (and the focus move that would start a
4623
+ // burst on the clicked block) before it happens; the trailing click is
4624
+ // consumed by the flag.
4625
+ e.preventDefault();
4626
+ blockSelClickSuppressed = true;
4627
+ clearNativeTextSelection();
4628
+ const base = blockSelection || (seed === null ? null : { anchorLine: seed, focusLine: seed });
4629
+ setBlockSelection(selectionLib.extendTo(base, line));
4630
+ }
4631
+
4632
+ // §4.4 entry (c) / its extension. Returns true when it owned the key.
4633
+ // stepFocus() skips blocks that own no source line, so every press MOVES —
4634
+ // Task 1 carry 3's "the first Shift+↓ does nothing, the second one moves".
4635
+ function stepSelectionFocus(dir) {
4636
+ if (!selectionLib) return false;
4637
+ if (blockSelection) {
4638
+ setBlockSelection(selectionLib.stepFocus(blockSelection, blocks, dir));
4639
+ return true;
4640
+ }
4641
+ // Entering fresh: the block that holds the roving focus, or — once a
4642
+ // clear has blurred it (see lastSelectionFocusLine) — the line the last
4643
+ // selection ended on, provided it still names a block that owns a line.
4644
+ let seed = focusedSelectableLine();
4645
+ if (seed === null && lastSelectionFocusLine !== null) {
4646
+ const rec = blocks.find((b) => b.startLine === lastSelectionFocusLine &&
4647
+ b.endLine >= b.startLine);
4648
+ if (rec) seed = rec.startLine;
4649
+ }
4650
+ if (seed === null) return false;
4651
+ setBlockSelection(selectionLib.stepFocus({ anchorLine: seed, focusLine: seed }, blocks, dir));
4652
+ return true;
4653
+ }
4654
+
3311
4655
  // ── T7: surviving a commit that renumbers every block id ───────────────
3312
4656
  // A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
3313
4657
  // that resolution can commit a DIFFERENT block's dirty editor, re-render,
@@ -3662,7 +5006,13 @@
3662
5006
  if (res.unsupported[i] !== 'MULTILINE') return false;
3663
5007
  }
3664
5008
  if (opts && opts.columnOnly) return true;
3665
- if (!targetEl) return multi.length === 0;
5009
+ // S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
5010
+ // one run and every one of them has to clear §4.1's multi-line gate, not
5011
+ // just the one the ⠿ was pressed on. An empty array is the same question as
5012
+ // no target at all (nobody is being rewritten).
5013
+ const targetEls = targetEl === null || targetEl === undefined
5014
+ ? [] : (Array.isArray(targetEl) ? targetEl : [targetEl]);
5015
+ if (!targetEls.length) return multi.length === 0;
3666
5016
  // T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
3667
5017
  // `multiLineBlockIds` reports a '\n' in the item's surface text, which
3668
5018
  // sees a LAZY continuation and is blind to a markdown HARD BREAK (two
@@ -3675,12 +5025,15 @@
3675
5025
  // Chromium leaves when the last character is deleted, and an emptied item
3676
5026
  // must stay removable). `multi` is kept as well — it costs nothing and
3677
5027
  // 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;
5028
+ for (let t = 0; t < targetEls.length; t++) {
5029
+ const targetRaw = targetEls[t].getAttribute('data-block-id');
5030
+ const targetRec = blocks.find((b) => b.id === Number(targetRaw));
5031
+ if (targetRec && targetRec.endLine > targetRec.startLine) return false;
5032
+ // getAttribute() strings on both sides — the same convention
5033
+ // unsupportedByLi[].blockId uses.
5034
+ if (multi.indexOf(targetRaw) !== -1) return false;
5035
+ }
5036
+ return true;
3684
5037
  }
3685
5038
 
3686
5039
  // Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
@@ -4157,6 +5510,222 @@
4157
5510
  return true;
4158
5511
  }
4159
5512
 
5513
+ // ── S3 Task 7: §3.5's batch Tab / Shift+Tab over a standing selection ──
5514
+ //
5515
+ // The whole task in one sentence: compute the delta ONCE, from the member
5516
+ // with the MINIMUM old indent, apply it to the whole set, and only THEN
5517
+ // clamp per item. Running the single-item arithmetic per member instead
5518
+ // breaks the sibling relationships the user selected — §3.5's own worked
5519
+ // example is `- a / (2sp)- b / (2sp)- c` with b+c selected: per item, c's
5520
+ // ceiling is 2 (because b sits at 1) so c moves there and is ADOPTED as b's
5521
+ // child, when the correct answer is that nothing moves at all.
5522
+ //
5523
+ // And the one number is the MINIMUM AVAILABLE HEAD-ROOM across the members,
5524
+ // not the head-room of the shallowest one. This is review defect D1
5525
+ // (2026-08-31), MEASURED on 8486ecd: `- a / (2sp)- b / (2sp)- c / - d` with
5526
+ // b, c and d selected took its delta from d (the minimum INDENT, +1) and
5527
+ // applied it to all three; applyIndentClamp() then walked the operated
5528
+ // blocks in document order, pulled b back to its own ceiling of 1 — and
5529
+ // recomputed c's ceiling against the JUST-SETTLED b, so c was allowed to
5530
+ // stay at 2 and became b's CHILD. Only the constrained member came back.
5531
+ // That is the 「半移動」 §3.4 rule 4 forbids: 「段內相對關係必須保持」.
5532
+ //
5533
+ // So a member with no head-room floors the WHOLE set at zero. Three reasons
5534
+ // this is the branch and not "move what can move":
5535
+ // * it is what Shift+Tab has always done — its delta is floored by a
5536
+ // member already at indent 0, and T7 carry 5 records that as a
5537
+ // deliberate no-op for exactly this reason;
5538
+ // * §3.4 rule 4 requires the members' relative relationships to survive
5539
+ // the operation, and a partial move is precisely what breaks them;
5540
+ // * CHANGELOG v2.12.0 already promises it — "a set that cannot move as a
5541
+ // whole does not move at all rather than half-moving".
5542
+ //
5543
+ // ⚠ §3.4 rule 3's SECOND worked example is written the other way round
5544
+ // (「批次 Tab 選 b(1)..d(0) 時第一成員 b 已在上界 ⇒ delta 0 ⇒ 整批 no-op」 is
5545
+ // listed there as a FAILURE of first-member anchoring). It cannot be
5546
+ // satisfied at the same time as rule 4 on the D1 fixture — d moving while b
5547
+ // and c are clamped IS the half-move — and the review ruled for rule 4. The
5548
+ // b(1)..d(0) set is therefore a whole-batch no-op now, and T7's scenario for
5549
+ // it was migrated with that reason in its assertion message.
5550
+ //
5551
+ // spanMinIndent() is still §3.4 rule 3's anchor VALUE for applyIndentClamp()
5552
+ // — the bound the blocks BELOW the set are re-measured against — and that
5553
+ // use is unchanged. Only the set's own movement is computed here.
5554
+
5555
+ // How far ONE member may move, as a magnitude (0 or 1), never a signed
5556
+ // delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
5557
+ // indentListItem()'s own single-item rule — the list-start clause included,
5558
+ // so a set that opens a list cannot nest itself under the previous list's
5559
+ // last item — and the floor is outdentListItem()'s (indent 0 cannot rise).
5560
+ // Both read PRE-move indents, which is §3.4's global convention ("the
5561
+ // operated block's indent" always means the value before the operation), and
5562
+ // it is what makes the minimum below well-defined: every member is measured
5563
+ // against the document as it stands, not against members already moved.
5564
+ function memberIndentHeadroom(el, dir) {
5565
+ const self = liAttrs(el);
5566
+ if (!self) return 0;
5567
+ if (dir < 0) return self.indent > 0 ? 1 : 0;
5568
+ if (self.listStart) return 0;
5569
+ const all = allBlockEls();
5570
+ const i = all.indexOf(el);
5571
+ if (i < 0) return 0;
5572
+ const prev = liAttrs(all[i - 1]);
5573
+ const max = prev ? prev.indent + 1 : 0;
5574
+ return Math.max(0, Math.min(1, max - self.indent));
5575
+ }
5576
+
5577
+ // How far the WHOLE set may move, as one SIGNED number: the smallest
5578
+ // head-room any member has, in `dir`'s direction. Zero for an empty set and
5579
+ // zero the moment one member cannot move, which is the D1 ruling.
5580
+ function batchIndentDelta(liEls, dir) {
5581
+ const els = liEls || [];
5582
+ if (!els.length) return 0;
5583
+ let room = 1;
5584
+ for (let i = 0; i < els.length; i++) {
5585
+ const r = memberIndentHeadroom(els[i], dir);
5586
+ if (r < room) room = r;
5587
+ if (room === 0) return 0;
5588
+ }
5589
+ return dir < 0 ? -room : room;
5590
+ }
5591
+
5592
+ // The li half. One mutation, one clamp, one commit — never a loop over the
5593
+ // members, for the reason the whole batch layer exists: a loop re-renders
5594
+ // between items and invalidates every id in between.
5595
+ async function indentListItemsBySelection(liEls, recs, dir) {
5596
+ // Every list batch is one run's problem — see batchRunOf().
5597
+ const run = batchRunOf(liEls);
5598
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
5599
+ // `columnOnly`: an indent change rewrites nothing but leading columns, so a
5600
+ // hard-wrapped member is a legal TARGET here — the same deviation the caret
5601
+ // Tab's own gate documents, and in a batch every member is a target.
5602
+ if (!listRunSupportsStructuralEdit(run, liEls, { columnOnly: true })) {
5603
+ refuseStructuralListEdit(); return;
5604
+ }
5605
+ const oldIndent = spanMinIndent(liEls);
5606
+ const delta = batchIndentDelta(liEls, dir);
5607
+ // A zero delta is a COMPLETE no-op — nothing mutated, nothing committed,
5608
+ // file byte-identical, the set left standing — exactly what the single-item
5609
+ // Tab does at its own boundary, and NOT a refusal (no banner: the spec's
5610
+ // 「靜默不動作是缺陷」 is about refusals, and this is the documented
5611
+ // boundary answer §3.5 gives for a set at its ceiling). On §3.5's b+c
5612
+ // example this IS the right answer, and it is the answer per-item maths
5613
+ // cannot give; after D1 it is also the answer whenever ANY member is at
5614
+ // its ceiling, which is what keeps the members' relative order intact.
5615
+ if (delta === 0) return;
5616
+ const first = recs[0];
5617
+ const last = recs[recs.length - 1];
5618
+ mutateListRun(() => {
5619
+ liEls.forEach((el) => setBlockIndent(el,
5620
+ Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + delta)));
5621
+ // ONE call for the whole set, never one per member: clampIndents() takes
5622
+ // an ARRAY of operated indices precisely so §3.4 rule 3 computes one
5623
+ // segment delta for the blocks below instead of N independent clamps, and
5624
+ // its rule 1 then walks the operated blocks in document order so each is
5625
+ // measured against the member above it that the same pass just settled.
5626
+ applyIndentClamp(run, liEls, oldIndent);
5627
+ });
5628
+ // Re-derived AFTER the mutation, like the caret Tab's: an indent change can
5629
+ // move a block between runs.
5630
+ const liveRun = listRunOf(liEls[0]);
5631
+ if (!liveRun.length) return;
5632
+ // Column-only: nothing's CONTENT moved, so every block in the span — the
5633
+ // members included — is a bystander whose source bytes must come back
5634
+ // untouched.
5635
+ const carry = bystanderCarryOver(liveRun, null);
5636
+ // §3.3's collapse, declared immediately before the commit's render (Task 5
5637
+ // carry 5) and only when a set was standing (Task 6 carry 6). A
5638
+ // column-only edit changes no line COUNT and moves no line, so the members
5639
+ // keep exactly the range they had.
5640
+ declareCollapse({ startLine: first.startLine, endLine: last.endLine });
5641
+ await commitListStructure(liveRun, null, false, { carryOver: carry });
5642
+ }
5643
+
5644
+ // The non-li half — §3.5's heading row, batched: Tab lowers a heading one
5645
+ // level (clamped to H6), Shift+Tab raises it (clamped to H1), and a
5646
+ // paragraph / quote / code member is a true no-op.
5647
+ //
5648
+ // The span is rewritten IN PLACE inside its own line range: each heading
5649
+ // member's line is re-emitted at the new depth and every other line —
5650
+ // separators and non-heading members alike — comes back verbatim. That is
5651
+ // what makes 「段落 no-op」 a real no-op instead of a re-serialization that
5652
+ // could move bytes nobody asked it to, and it is why this is ONE
5653
+ // commitRangeEdit and therefore one undo op.
5654
+ async function changeHeadingDepthsInSpan(els, recs, dir) {
5655
+ const first = recs[0];
5656
+ const last = recs[recs.length - 1];
5657
+ const span = lines.slice(first.startLine - 1, last.endLine);
5658
+ let changed = false;
5659
+ for (let i = 0; i < recs.length; i++) {
5660
+ if (recs[i].type !== 'heading') continue;
5661
+ const at = recs[i].startLine - first.startLine;
5662
+ const cur = span[at];
5663
+ // headingDepthOf() answers 1 for ANY line, matched or not, so a SETEXT
5664
+ // heading ('Title' over '====') would be handed a '#' it never asked for
5665
+ // and its underline left behind. Only a real ATX line is rewritten.
5666
+ if (typeof cur !== 'string' || !/^#{1,6}(\s|$)/.test(cur)) continue;
5667
+ const depth = headingDepthOf(cur);
5668
+ const next = Math.max(1, Math.min(6, depth + dir));
5669
+ if (next === depth) continue;
5670
+ span[at] = withHeadingDepth(cur, next);
5671
+ changed = true;
5672
+ }
5673
+ // Every member was a paragraph, or every heading already sat at its clamp:
5674
+ // §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
5675
+ if (!changed) return;
5676
+ const result = commitRangeEdit({ lines, blocks, stack },
5677
+ first.startLine, last.endLine, span.join('\n'));
5678
+ if (result.op === null) return;
5679
+ const prevLines = lines;
5680
+ lines = result.lines;
5681
+ // §3.3's collapse: a depth change rewrites no line COUNT, so the set
5682
+ // collapses onto the lines it already held.
5683
+ declareCollapse({ startLine: first.startLine, endLine: last.endLine });
5684
+ const okRender = await safeRerenderAll();
5685
+ if (!okRender) {
5686
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
5687
+ }
5688
+ }
5689
+
5690
+ // The keydown entry point. The "grip" is the roving focus holder, which is a
5691
+ // member of the set by construction, so resolveGutterOperands() answers
5692
+ // 'batch' and hands back the whole span — together with the shared preamble
5693
+ // every ⠿ operation already goes through (switchAwayFrom(), the re-resolve
5694
+ // after a commit that re-rendered, the no-source-line refusal, the emptiness
5695
+ // and contiguity gates). Membership resolves by REFERENCE against the live
5696
+ // `blocks`, which is exactly why the record must come from there and not from
5697
+ // a fresh find() (Task 1 carry 4).
5698
+ async function tabSelection(dir) {
5699
+ const focusEl = selectionFocusBlockEl();
5700
+ if (!focusEl) return;
5701
+ const operands = await resolveGutterOperands(focusEl);
5702
+ if (!operands) return;
5703
+ const els = operands.els;
5704
+ const recs = operands.recs;
5705
+ const shape = spanListKinds(els);
5706
+ // §3.6's 2026-08-31 ruling, inherited rather than re-invented. NOTE: this
5707
+ // CONTRADICTS the S3 plan's Task 7 text ("a batch containing both kinds
5708
+ // applies each rule to its own kind") — see the Task 7 carry. A mixed span
5709
+ // would need the run's survivors re-serialized at the same time as lines
5710
+ // outside the run are rewritten, i.e. two commits and therefore two undo
5711
+ // ops, which §3.4's 「一次使用者手勢 = 恰好一個 undo op」 forbids.
5712
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
5713
+ if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
5714
+ await changeHeadingDepthsInSpan(els, recs, dir);
5715
+ }
5716
+
5717
+ // §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
5718
+ // the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
5719
+ // is written. deleteBlockViaGutter() already resolves §3.3's membership for
5720
+ // itself and routes an all-li span to deleteListItemsViaGutter(), so the key
5721
+ // is one call — every refusal, every collapse and the one-undo-op guarantee
5722
+ // come with it for free rather than being restated here.
5723
+ async function deleteSelection() {
5724
+ const focusEl = selectionFocusBlockEl();
5725
+ if (!focusEl) return;
5726
+ await deleteBlockViaGutter(focusEl);
5727
+ }
5728
+
4160
5729
  // ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
4161
5730
  // Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
4162
5731
  // 8. Structurally different from Task 4's whole-list handleListKeydown()
@@ -4183,6 +5752,10 @@
4183
5752
  // no-source-line callers pass their own text instead of a second function.
4184
5753
  function refuseStructuralListEdit(message) {
4185
5754
  showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
5755
+ // Review recommendation 5: mark it, so the next SUCCESSFUL render clears
5756
+ // it (dismissRefusalBanner(), called from rerenderAll()). Set after the
5757
+ // showBanner() call, which resets the flag for every other caller.
5758
+ activeBannerIsRefusal = true;
4186
5759
  }
4187
5760
 
4188
5761
  // Row 3, top-level press: spec §4 — "at top level the next press converts
@@ -5019,8 +6592,9 @@
5019
6592
  // grips below are the fix: real, adequately-sized (≥18×24px) elements the
5020
6593
  // user can actually see and aim for. Both grips are overlay elements
5021
6594
  // `position: fixed`-appended to document.body — never DOM CHILDREN of a
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
6595
+ // contenteditable cell, even though the row grip visually overlaps one
6596
+ // (since §4.2 conflict 2 landed in S4 T1 it sits ENTIRELY inside the first
6597
+ // column, within that cell's own widened left padding; see the geometry
5024
6598
  // note in the "Notion-style row/column grip handles" section below). So —
5025
6599
  // unlike the old zones, which
5026
6600
  // sat INSIDE an already-permanently-contenteditable cell and needed the
@@ -5333,20 +6907,46 @@
5333
6907
  // listener (wired near the bottom of this file) that already drives
5334
6908
  // updateTableInsertBubbles() — see its own comment for the coalescing
5335
6909
  // contract this reuses.
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.
6910
+ // Geometry, per axis (they are NOT mirror images — see below):
6911
+ // COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
6912
+ // its hit rect straddles that border by half its own height on each
6913
+ // side (P0-a).
6914
+ // ROW grip: its LEFT EDGE coincides with the table's LEFT border, so the
6915
+ // whole grip is INSIDE the table. This is spec §4.2 hit-test
6916
+ // conflict 2's ruling, landed in S4 T1.
6917
+ //
6918
+ // Why the row axis had to break the symmetry. The block's own gutter ⠿
6919
+ // hangs outside the content box at [blockLeft-22, blockLeft-4], and a
6920
+ // table block's own table starts AT blockLeft, so a row grip centred on
6921
+ // the table's left border occupied [blockLeft-10, blockLeft+10] and
6922
+ // overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
6923
+ // positioned overlays and the grip is a document.body child at z-index 7,
6924
+ // so the grip won that hit test whenever it was showing — which is
6925
+ // whenever the pointer had recently been over a table cell. MEASURED on
6926
+ // v2.12.0 at 1400x900 with the row grip parked on the HEADER row (the only
6927
+ // row whose 28px-tall grip reaches the ⠿'s own top-20px band):
6928
+ // elementFromPoint() walked across the table block's ⠿ at its vertical
6929
+ // centre answered .ed-handle for x 390..401 and the ROW GRIP for x
6930
+ // 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
6931
+ // a table ROW drag instead of a block move.
6932
+ //
6933
+ // An earlier revision (v2.10.1) already tried the inset and was reverted,
6934
+ // because a 20px grip inside a cell whose left padding is 14px sits on
6935
+ // that cell's TEXT. That is fixed on the CSS side this time rather than by
6936
+ // giving the grip back its outside half: lib/md2doc.js pads the first
6937
+ // column to --ed-te-cell-pad-left (the grip's own width plus the gutter
6938
+ // pair's breathing gap) in EDIT MODE ONLY. The ⠿ ↔ grip clearance that
6939
+ // remains is exactly that same breathing-gap token — see the edit-mode
6940
+ // :root block in lib/md2doc.js, which spells both tokens out; this file
6941
+ // deliberately does not name them, because test/editor-client.test.js
6942
+ // forbids the retired hover-gutter class name as a bare substring and the
6943
+ // token names contain it. Neither edge is written down twice, so the two
6944
+ // cannot drift apart again — drift is what produced this defect (the CSS
6945
+ // comment claiming "an 8px gap" was describing v2.11.0's pair position,
6946
+ // which v2.11.1 replaced).
6947
+ // test/editor-client-runtime.test.js asserts BOTH directions: every pixel
6948
+ // of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
6949
+ // grip's own centre answers the row grip.
5350
6950
  // Either way the grip's hit rect DOES overlap the insert bubble's hit
5351
6951
  // rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
5352
6952
  // own axis). Non-intersection via rect separation is no longer possible
@@ -5425,12 +7025,16 @@
5425
7025
  // gripCenter(), which jump straight to the grip's own coordinates) could
5426
7026
  // ever land on it; a real mouse gesture could not.
5427
7027
  //
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.
7028
+ // Current geometry (the two axes DIVERGED in S4 T1, §4.2 conflict 2): the
7029
+ // COLUMN grip still straddles the table's TOP border, half above / half
7030
+ // below, so the corridor-crossing bug described above still applies to it
7031
+ // exactly as originally described and pointInColGripZone() still covers
7032
+ // the outside-the-border corridor. The ROW grip is now entirely INSIDE the
7033
+ // table (its left edge on the table's left border), so a pointer
7034
+ // travelling from a cell to it never leaves the table and there is no
7035
+ // corridor to cross — pointInRowGripZone() is the padded grip rect alone.
7036
+ // See its own comment for why the old formula was deleted rather than
7037
+ // left in place to evaluate to the empty set.
5434
7038
  //
5435
7039
  // Review fix (Important, first pass over-permissive): the first version of
5436
7040
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -5443,14 +7047,30 @@
5443
7047
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
5444
7048
  // pointInColGripZone() below) instead of the whole table.
5445
7049
  //
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.
7050
+ // What each keep-zone covers TODAY — and the two axes diverged when §4.2
7051
+ // conflict 2 moved the row grip inside the table (S4 T1):
7052
+ // pointInColGripZone() still keeps the ORIGINAL corridor shape: the
7053
+ // union of (the column grip's own padded rect) and (the straight strip
7054
+ // between the grip's own TOP edge and the table's top border, x clamped
7055
+ // to the anchor COLUMN's own horizontal extent, padded). The column
7056
+ // grip still straddles the top border, so that corridor is still real.
7057
+ // pointInRowGripZone() is now the padded grip rect ALONE. Its corridor
7058
+ // was the strip between the grip's own LEFT edge and the table's LEFT
7059
+ // border; with the grip's left edge ON that border the strip is empty,
7060
+ // and §4.6 explicitly warns that leaving the formula in place would
7061
+ // make it (and the two scenarios downstream of it) silently vacuous
7062
+ // rather than red. It is deleted rather than left to read as if it did
7063
+ // something. Nothing is lost: the corridor existed because a pointer
7064
+ // travelling from a cell to a border-straddling grip had to leave the
7065
+ // table on the way, and a grip that is entirely inside the table has no
7066
+ // such approach — the pointer goes cell -> grip, and the
7067
+ // `target.closest('.ed-te-grip-row')` early return at the top of
7068
+ // updateTableEdgeGrips() covers the moment it arrives. The observable
7069
+ // change is that leaving the table on the left now hides the row grip
7070
+ // immediately, at the anchor row's own height as well as at any other
7071
+ // row's; the scenario named below asserts both halves, because "hidden
7072
+ // at another row's height" alone is true of any geometry once the
7073
+ // corridor is gone.
5454
7074
  // A pointer outside either grip's own zone is a genuine exit and still
5455
7075
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
5456
7076
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -5470,15 +7090,17 @@
5470
7090
  function pointInRowGripZone(x, y) {
5471
7091
  if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
5472
7092
  !document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
5473
- const gr = rowGrip.getBoundingClientRect();
5474
- if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
5475
- // grip 跨在表格左邊界上,所以「從儲存格走向 grip」必定經過邊界外側的
5476
- // 那半個 grip 寬度。走廊=從 grip 自己的左緣到表格左緣,垂直方向夾在
5477
- // 所錨定那一列的上下緣(加 TE_GRIP_ZONE_PAD_PX 的次像素寬容)。
5478
- const rowRect = gripRowEl.getBoundingClientRect();
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;
7093
+ // §4.2 衝突 2(S4 T1)之後 row grip 整個落在表格內側,左緣就壓在表格
7094
+ // 左邊界上,所以舊的走廊公式 `x >= gr.left && x <= tableRect.left` 塌成
7095
+ // 空集合。§4.6 已經預告過這件事:留著它不會變紅,只會變成空測。這裡
7096
+ // 直接刪掉,keep-zone 只剩 grip 自己的 padded rect。
7097
+ // 走廊本來只為了一件事存在——指標從儲存格走向一個跨在邊界上的 grip
7098
+ // 時必定會先離開表格;grip 內移之後那條路徑整段都在表格內(先是
7099
+ // 儲存格,接著是 grip 自己,由 updateTableEdgeGrips() 開頭那個
7100
+ // `target.closest('.ed-te-grip-row')` 早退接手),所以沒有走廊可走。
7101
+ // 對使用者可見的差別是:從左側離開表格會立刻收掉 row grip,錨定那一
7102
+ // 列的高度也不例外。
7103
+ return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
5482
7104
  }
5483
7105
 
5484
7106
  function pointInColGripZone(x, y) {
@@ -5507,9 +7129,10 @@
5507
7129
  function updateTableEdgeGrips(x, y, target) {
5508
7130
  // Both grips are `position: fixed` overlays appended to document.body
5509
7131
  // (same as the hover-insert bubbles) rather than descendants of the
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
7132
+ // table — the column grip PAINTS half outside it (straddling the top
7133
+ // border) and the row grip paints inside the first column (§4.2
7134
+ // conflict 2). Either way neither is a table descendant, so the moment
7135
+ // the real pointer crosses from a cell onto
5513
7136
  // the grip itself, `target` is the grip and is no
5514
7137
  // longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
5515
7138
  // Without this guard, that transition would hit the "nothing found"
@@ -5544,8 +7167,11 @@
5544
7167
  // Row grip: every row, including the header — the first row of a
5545
7168
  // markdown table IS the header, so any row must be draggable to the
5546
7169
  // 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
7170
+ // 「grip 位置都一樣」) — its LEFT EDGE on the table's left border, i.e.
7171
+ // entirely inside the table (spec §4.2 hit-test conflict 2, landed
7172
+ // S4 T1 — see the geometry note above buildTableGrip() for the
7173
+ // measurement that forced it), vertically centred on its own row; no
7174
+ // header special case. The
5549
7175
  // one exception is a header-only table (no body rows): its single row
5550
7176
  // is thead's only row, and dragging it away would empty the thead —
5551
7177
  // serializeTable() would degrade it and the user's table would vanish
@@ -5554,13 +7180,19 @@
5554
7180
  gripRowTableEl = tableEl;
5555
7181
  gripRowEl = rowEl;
5556
7182
  const r = rowEl.getBoundingClientRect();
5557
- // Fallback dims match .ed-te-grip-row's own CSS width/height exactly
5558
- // (20x28) — offsetWidth/Height read 0 while `hidden` (display: none)
5559
- // is still true on the FIRST show of a hover session, before the
5560
- // `hidden = false` assignment below takes effect.
5561
- const gw = rowGrip.offsetWidth || 20;
7183
+ // Fallback height matches .ed-te-grip-row's own CSS height exactly
7184
+ // (28) — offsetHeight reads 0 while `hidden` (display: none) is still
7185
+ // true on the FIRST show of a hover session, before the
7186
+ // `hidden = false` assignment below takes effect. There is no width
7187
+ // fallback any more, and that is the point: the left edge is the
7188
+ // table's own border, so the grip's WIDTH is not an input to its
7189
+ // position at all. It used to be (`tableRect.left - gw / 2`), which
7190
+ // meant the CSS width and this literal `20` had to agree for the
7191
+ // gutter clearance to hold — exactly the drift §4.2 conflict 2 is
7192
+ // about. The width now lives in one place only, the
7193
+ // --ed-te-grip-row-w token in lib/md2doc.js.
5562
7194
  const gh = rowGrip.offsetHeight || 28;
5563
- rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
7195
+ rowGrip.style.left = tableRect.left + 'px';
5564
7196
  rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
5565
7197
  rowGrip.hidden = false;
5566
7198
  } else {
@@ -5914,6 +7546,820 @@
5914
7546
  currentBurst.history.snap('drag-col');
5915
7547
  }
5916
7548
 
7549
+ // ── S4 Task 2: dragging a BLOCK by its ⠿ handle (spec §4.5) ─────────────
7550
+ // The gesture SKELETON only — threshold, capture, drop-target computation,
7551
+ // the indicator, and every cancellation path. THE DROP IS A NO-OP; Tasks
7552
+ // 3-7 add the commits. §4.5 says to reuse the table row drag's skeleton
7553
+ // above, and this does, including its central invariant:
7554
+ //
7555
+ // NOTHING IN THE DOM IS MUTATED DURING A DRAG. Only the indicator moves.
7556
+ //
7557
+ // That is the whole reason cancelBlockDrag() can be called unconditionally
7558
+ // from Escape / pointercancel / window blur / the next pointerdown /
7559
+ // rerenderAll() / resolveBurst() with nothing to revert — see
7560
+ // cancelTeDrag()'s own comment for the same argument on the row axis. It
7561
+ // also means no `class=""` residue can reach a burst's zero-edit guard (the
7562
+ // row path had to strip one by hand, because it DIMS the dragged <tr>; this
7563
+ // gesture deliberately dims nothing, so the question never arises). Even the
7564
+ // ⠿ menu, which lives as a CHILD of its block and would therefore be a
7565
+ // content mutation to close, is left alone until the gesture is over.
7566
+ // S4 Task 2b: the drag-cursor class, on `document.documentElement`. Named
7567
+ // once so the add, the remove and lib/md2doc.js's rule cannot drift apart.
7568
+ const BLOCK_DRAGGING_CLASS = 'ed-block-dragging';
7569
+ const blockDropIndicator = document.createElement('div');
7570
+ blockDropIndicator.className = 'ed-block-drop-indicator';
7571
+ blockDropIndicator.hidden = true;
7572
+ document.body.appendChild(blockDropIndicator);
7573
+
7574
+ // The drop-target candidates: the document's block sequence, in document
7575
+ // order. MEASURED on this branch (1400x900, a doc mixing paragraphs, a
7576
+ // heading, a list run with a nested item, a same-line-nest PHANTOM, a
7577
+ // fenced code block and a table): every single `.ed-block` is a DIRECT
7578
+ // child of `contentEl` — S1 flattened the <ul>/<ol> containers away — so
7579
+ // allBlockEls() already IS that sequence, with no nesting to flatten and no
7580
+ // second, overlapping y-range sitting inside another candidate's box.
7581
+ // Named rather than inlined because Tasks 3-7 re-resolve against it.
7582
+ function blockDropCandidates() {
7583
+ return allBlockEls();
7584
+ }
7585
+
7586
+ // Nearest block-drop target for `clientY` — a discriminated union:
7587
+ // {mode: 'before-block', blockIndex, y} | {mode: 'append', y}
7588
+ // `blockIndex` is an ORDINAL into blockDropCandidates(); never a
7589
+ // `data-block-id` (blockmap.js renumbers 0..n-1 on every render, so a
7590
+ // captured id names the target's NEIGHBOUR after any commit that changes
7591
+ // the block count) and never a node (a mid-gesture commit detaches them).
7592
+ //
7593
+ // spec §4.6 replaced the table's `{beforeRow, y}` with exactly this shape,
7594
+ // because a null `beforeRow` had been overloaded to mean "append". That
7595
+ // ruling is followed here rather than nearestColDropTarget()'s flat
7596
+ // `{index, x}`, which is the one place it was never applied.
7597
+ //
7598
+ // There is no 'above-header' counterpart: a table's first row IS its
7599
+ // header, so promoting a row to first place needed its own mode; the
7600
+ // document has no such distinguished first block, and `before-block 0`
7601
+ // already says "become the new first block".
7602
+ function nearestBlockDropTarget(clientY) {
7603
+ const els = blockDropCandidates();
7604
+ for (let i = 0; i < els.length; i++) {
7605
+ const r = els[i].getBoundingClientRect();
7606
+ if (clientY < r.top + r.height / 2) {
7607
+ return { mode: 'before-block', blockIndex: i, y: r.top };
7608
+ }
7609
+ }
7610
+ const last = els[els.length - 1];
7611
+ const rect = last ? last.getBoundingClientRect() : contentEl.getBoundingClientRect();
7612
+ return { mode: 'append', y: rect.bottom };
7613
+ }
7614
+
7615
+ // The in-flight ⠿ gesture, or null between gestures — the block-axis
7616
+ // counterpart of `tePointer`. `sourceIndex` is the ORDINAL of the pressed
7617
+ // block AT PRESS TIME, which is the table drop's own rule ("convert the
7618
+ // pressed node to an ordinal first, re-resolve in the live DOM at drop
7619
+ // time"): between the press and the release, resolveGutterOperands()'s
7620
+ // switchAwayFrom() may commit some other dirty burst, re-render, and detach
7621
+ // every node in the document. `dragging` flips true once
7622
+ // TE_DRAG_THRESHOLD_PX is crossed; `dropTarget` is the union above, filled
7623
+ // in by updateBlockDropIndicator() as the pointer moves.
7624
+ let blockDragState = null;
7625
+
7626
+ // Arms the gesture for a press on a ⠿. Returns TRUE when it owns this
7627
+ // pointer, so the `pointerdown` listener below can stop before it reaches
7628
+ // beginShiftClickSelection()/armBlockSelDrag().
7629
+ //
7630
+ // Running FIRST is required and is also free: `.ed-handle` is a member of
7631
+ // ED_SEL_GESTURE_CHROME, so armBlockSelDrag()'s very first line already
7632
+ // returned for exactly this target (and beginShiftClickSelection()'s does
7633
+ // too) — which is why a press on the ⠿ recorded NO pointer state at all
7634
+ // before this task. Nothing about a press anywhere else changes.
7635
+ //
7636
+ // No preventDefault() here, deliberately. wireBlockSelection()'s delegated
7637
+ // `mousedown` preventDefault() on `.ed-handle` is what keeps a dirty
7638
+ // burst's focus intact across a ⠿ press — without it the press blurs the
7639
+ // burst, the blur commits, the commit re-renders, the button is detached
7640
+ // between mousedown and mouseup and the `click` never fires at all (the
7641
+ // swallowed-first-click defect that fix exists for). Cancelling
7642
+ // `pointerdown` additionally suppresses the compatibility `mousedown` in
7643
+ // some engines, i.e. the very event that fix lives on. updateBlockDrag()
7644
+ // does the preventDefault() instead, once the gesture is definitely a drag.
7645
+ function armBlockDrag(e) {
7646
+ const handleEl = e.target && e.target.closest ? e.target.closest('.ed-handle') : null;
7647
+ if (!handleEl) return false;
7648
+ const blockEl = handleEl.closest('.ed-block');
7649
+ if (!blockEl) return false;
7650
+ const index = blockDropCandidates().indexOf(blockEl);
7651
+ if (index < 0) return false;
7652
+ blockDragState = {
7653
+ pointerId: e.pointerId,
7654
+ sourceIndex: index,
7655
+ // Carried for Tasks 3-7's own re-resolution cross-check only; the
7656
+ // ORDINAL above is the identity, for the renumbering reason spelt out
7657
+ // on `blockDragState`.
7658
+ sourceBlockId: blockEl.getAttribute('data-block-id'),
7659
+ startX: e.clientX, startY: e.clientY,
7660
+ dragging: false,
7661
+ dropTarget: null,
7662
+ captureEl: null,
7663
+ };
7664
+ return true;
7665
+ }
7666
+
7667
+ // Repositions the singleton line onto the current drop target. All four
7668
+ // dimensions are written on every move — the node is this gesture's alone
7669
+ // (see the class comment in lib/md2doc.js for why it is not the table's),
7670
+ // but the content column can be resized by a sidebar drag or a window
7671
+ // resize between two frames, so a partially-written box would leave a stale
7672
+ // width behind.
7673
+ function updateBlockDropIndicator(clientY) {
7674
+ const target = nearestBlockDropTarget(clientY);
7675
+ blockDragState.dropTarget = target;
7676
+ const els = blockDropCandidates();
7677
+ const spanEl = target.mode === 'append' ? els[els.length - 1] : els[target.blockIndex];
7678
+ const r = spanEl ? spanEl.getBoundingClientRect() : contentEl.getBoundingClientRect();
7679
+ blockDropIndicator.style.left = r.left + 'px';
7680
+ blockDropIndicator.style.width = r.width + 'px';
7681
+ blockDropIndicator.style.top = (target.y - 1) + 'px';
7682
+ blockDropIndicator.style.height = '3px';
7683
+ }
7684
+
7685
+ // Capture on documentElement at ENGAGE time, not at press time — the S3
7686
+ // selection drag's reasoning (captureBlockSelDrag()) applies verbatim here
7687
+ // and the table's does not. A press on a ⠿ that turns out to be a plain
7688
+ // click must not be interfered with AT ALL: it has to reach `click` and open
7689
+ // the menu. And the pressed node is a per-block button that rerenderAll()
7690
+ // and openRawEditor()'s restore() both re-create, so capturing on it would
7691
+ // pin a node the gesture can outlive.
7692
+ function captureBlockDrag(e) {
7693
+ const el = document.documentElement;
7694
+ if (el && typeof el.setPointerCapture === 'function') {
7695
+ try { el.setPointerCapture(e.pointerId); blockDragState.captureEl = el; }
7696
+ catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
7697
+ }
7698
+ }
7699
+
7700
+ function releaseBlockDragCapture(st) {
7701
+ if (st && st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
7702
+ try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
7703
+ }
7704
+ }
7705
+
7706
+ function updateBlockDrag(e) {
7707
+ if (!blockDragState || e.pointerId !== blockDragState.pointerId) return;
7708
+ if (tePointer) return; // a grip gesture owns this pointer
7709
+ // The browser can simply never deliver a pointerup (released over browser
7710
+ // chrome, over another window). The next move with no button held is the
7711
+ // only signal left that the gesture is over — the same guard
7712
+ // updateBlockSelDrag() carries, for the same reason.
7713
+ // S4 Task 3: endBlockDrag() is async now. Deliberately not awaited here —
7714
+ // `updateBlockDrag` is called from a `pointermove` listener that cannot
7715
+ // await, and this branch is the last-resort recovery for a pointerup the
7716
+ // browser never delivered. The teardown inside it is synchronous either
7717
+ // way; only the drop's own commit trails.
7718
+ if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockDrag(); return; }
7719
+ if (!blockDragState.dragging) {
7720
+ const dx = e.clientX - blockDragState.startX, dy = e.clientY - blockDragState.startY;
7721
+ if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
7722
+ blockDragState.dragging = true;
7723
+ captureBlockDrag(e);
7724
+ blockDropIndicator.hidden = false;
7725
+ // S4 Task 2b (user request, 2026-09-01): the ONLY feedback the source
7726
+ // block gets. `.ed-handle` is `opacity: 0` off-hover, so from the moment
7727
+ // the pointer leaves the block there is nothing under the cursor but the
7728
+ // indicator line. The ruling is a `cursor: grabbing` class on
7729
+ // `document.documentElement` — OUTSIDE `contentEl`, so the "mutate
7730
+ // nothing in the content DOM during a drag" invariant this whole gesture
7731
+ // rests on still holds, and every cancellation path stays unconditional.
7732
+ // The table drag's `ed-te-row-dragging` dim is deliberately NOT the
7733
+ // model: it dims a node INSIDE the content, and the burst's zero-edit
7734
+ // guard compares `contentEl`'s innerHTML, so the residue it leaves had
7735
+ // to be stripped by hand.
7736
+ //
7737
+ // ⚠ CORRECTED (S4 review round, 2026-09-01). The earlier wording here
7738
+ // and in §4.5 said documentElement AVOIDS that `class=""` residue. It
7739
+ // does not. classList.add() on an element with no `class` attribute
7740
+ // CREATES one and remove() only empties its value — measured in the same
7741
+ // Chromium: `<html class="">` after every engaged drag, every time. The
7742
+ // residue is not avoided, it is RELOCATED: `<html>` is a node nothing in
7743
+ // this file compares, `contentEl.innerHTML` is a string the zero-edit
7744
+ // guard reads on every commit. The ruling is unchanged (the cost really
7745
+ // is zero); only its stated reason was wrong.
7746
+ document.documentElement.classList.add(BLOCK_DRAGGING_CLASS);
7747
+ // Nothing else is touched here. The table's hover chrome (insert
7748
+ // bubbles, grips) is hidden by the rAF `mousemove` gate below, which is
7749
+ // where the row drag and the S3 selection drag both do it too; and the
7750
+ // ⠿ / + menus are left alone until endBlockDrag(), because closing one
7751
+ // detaches a node from INSIDE a block and would break the
7752
+ // "nothing is mutated during a drag" invariant this whole gesture rests
7753
+ // on.
7754
+ }
7755
+ e.preventDefault();
7756
+ updateBlockDropIndicator(e.clientY);
7757
+ }
7758
+
7759
+ // Shared teardown. Both exits below go through it, so a future drop added
7760
+ // to endBlockDrag() can never be reached from a cancellation path.
7761
+ function teardownBlockDrag() {
7762
+ if (!blockDragState) return null;
7763
+ const st = blockDragState;
7764
+ blockDragState = null;
7765
+ releaseBlockDragCapture(st);
7766
+ blockDropIndicator.hidden = true;
7767
+ // S4 Task 2b: removed HERE and nowhere else. T2 routed endBlockDrag() and
7768
+ // cancelBlockDrag() through this one function precisely so a later
7769
+ // addition inherits all five abort paths (pointerup, Escape,
7770
+ // pointercancel, window blur, the next pointerdown / every re-render) for
7771
+ // free. Unconditional on `.dragging`: classList.remove() of a class that
7772
+ // was never added is a no-op, and a gesture that ended before the
7773
+ // threshold never added it.
7774
+ document.documentElement.classList.remove(BLOCK_DRAGGING_CLASS);
7775
+ // A gesture that became a drag owns its own trailing `click`: released
7776
+ // outside every block it would reach wireBlockSelection()'s
7777
+ // switchAwayFrom(); released back over the ⠿ it started on it would OPEN
7778
+ // THE MENU the user was not asking for. Same one-shot flag the S3
7779
+ // selection drag sets, re-armed to false by the next pointerdown so a
7780
+ // gesture whose click never arrives cannot swallow a later one.
7781
+ if (st.dragging) blockSelClickSuppressed = true;
7782
+ return st;
7783
+ }
7784
+
7785
+ // The `pointerup` half. S4 Task 3 makes the drop REAL for a single block
7786
+ // that is not a list item; Tasks 4-7 widen it. The teardown above is
7787
+ // synchronous and happens FIRST, so the gesture is already over (indicator
7788
+ // down, capture released, `ed-block-dragging` off <html>) before a single
7789
+ // byte is considered — nothing the commit does can leave drag state behind.
7790
+ //
7791
+ // ASYNC, and the `pointerup` listener awaits it: the drop resolves an open
7792
+ // burst and re-renders, and a fire-and-forget promise would let a test (or
7793
+ // the next gesture) observe the document between the release and the
7794
+ // commit's own /api/render.
7795
+ async function endBlockDrag() {
7796
+ const st = teardownBlockDrag();
7797
+ if (!st || !st.dragging) return;
7798
+ // The menus are closed AFTER the gesture rather than at engage: closing
7799
+ // one detaches a node from inside a block, and mutating nothing during a
7800
+ // drag is what makes every cancellation path above unconditional.
7801
+ closeGutterMenu();
7802
+ closeInsertMenu();
7803
+ await performBlockDrop(st);
7804
+ }
7805
+
7806
+ // ── S4 Task 3: the drop (spec §4.5) ───────────────────────────────────
7807
+ // Everything up to here has been chrome. This is where the ⠿ drag writes
7808
+ // bytes, through the pure commitBlockMove() above: ONE commitRangeEdit over
7809
+ // the min..max span, hence exactly one Ctrl+Z.
7810
+ //
7811
+ // The pressed block is named by ORDINAL, never by node and never by id —
7812
+ // the table drop's own rule. `blockmap.js` renumbers ids 0..n-1 on every
7813
+ // render, so a captured id names the target's NEIGHBOUR after any commit
7814
+ // that changes the block count, and every node in the document is detached
7815
+ // by a re-render. The ordinal is resolved in the LIVE DOM here, before
7816
+ // resolveGutterOperands() is allowed to commit anything.
7817
+ //
7818
+ // The DESTINATION needs the same protection and gets it one step earlier:
7819
+ // its identity is captured BEFORE resolveGutterOperands(), whose
7820
+ // switchAwayFrom() may commit some other block's dirty burst and re-render.
7821
+ // reresolveBlockEl() then proves the block sitting at that start line
7822
+ // afterwards really is the same one, exactly as the source's own re-resolve
7823
+ // does; when it cannot, the gesture is DROPPED with a banner rather than
7824
+ // completed against a block the user did not point at.
7825
+ async function performBlockDrop(st) {
7826
+ const target = st.dropTarget;
7827
+ // ⚠ S4 Task 6 audited this for §3.6 (「靜默不動作是缺陷」) and it is the one
7828
+ // silent exit in this function that NO GESTURE CAN REACH: performBlockDrop()
7829
+ // is called only from endBlockDrag()'s `st.dragging` branch, engaging sets
7830
+ // `dragging` and then calls updateBlockDropIndicator(), which assigns
7831
+ // `dropTarget` UNCONDITIONALLY from nearestBlockDropTarget() — and that
7832
+ // function has no null answer at all, falling through to
7833
+ // `{mode:'append'}` when no block's mid-line is below the pointer. Kept as
7834
+ // a defensive guard, not as a behaviour.
7835
+ if (!target) return; // engaged but never moved onto a target — nothing to do
7836
+ const els = blockDropCandidates();
7837
+ const sourceEl = els[st.sourceIndex];
7838
+ const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
7839
+ if (!sourceEl || (target.mode !== 'append' && !destEl)) {
7840
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
7841
+ return;
7842
+ }
7843
+ const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
7844
+ // ...and the SAME question resolveGutterOperands() asks about the source,
7845
+ // asked about the destination, BEFORE the commit that answers it.
7846
+ //
7847
+ // S4 review round (2026-09-01), MEDIUM: a drop onto a block with an open
7848
+ // DIRTY burst was ALWAYS refused. Measured on
7849
+ // '# Doc\n\nalpha\n\nbravo\n\ncharlie\n' — type ' EDITED' into
7850
+ // `charlie`, drag `alpha` onto charlie's top edge — and the answer was
7851
+ // '文件已更新,請重試這個操作' with the edit committed and the move not.
7852
+ //
7853
+ // It is an ASYMMETRY, not a race, and it could not be anything else:
7854
+ // reresolveBlockEl()'s fingerprint is the block's SOURCE, and the commit
7855
+ // that changed that source is this drop's OWN resolveGutterOperands() →
7856
+ // switchAwayFrom(). The fingerprint is therefore GUARANTEED to miss. The
7857
+ // source side has had reresolveBlockElAfterSelfCommit() for exactly this
7858
+ // since S2; this is its destination-side counterpart, gated the same way
7859
+ // — on ownsOpenSession() read BEFORE the commit, because `currentBurst` is
7860
+ // gone afterwards — so reresolveBlockEl() keeps its fingerprint for
7861
+ // everybody else. Deliberately NOT a widening of that function: S2's own
7862
+ // note says the narrowed variant exists precisely so no other caller loses
7863
+ // the fingerprint.
7864
+ const destSelfSession = destEl ? ownsOpenSession(destEl) : false;
7865
+ // The shared gutter preamble — identity capture, switchAwayFrom(), the
7866
+ // re-resolve after a commit that re-rendered, the no-source-line refusal
7867
+ // and §3.3's membership. Not re-implemented here: 「不得另寫一條」.
7868
+ const operands = await resolveGutterOperands(sourceEl);
7869
+ if (!operands) return;
7870
+ let liveDestEl = destEl;
7871
+ if (destEl && !document.body.contains(destEl)) {
7872
+ liveDestEl = reresolveBlockEl(destIdentity) ||
7873
+ (destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
7874
+ if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
7875
+ }
7876
+ // ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
7877
+ // §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
7878
+ // this read `if (operands.batch) return;` — the last two SILENT shapes in
7879
+ // the drag path, which §3.6 calls a defect in so many words
7880
+ // (「靜默不動作是缺陷」). From here down `opEls` is a span of one OR MORE
7881
+ // blocks and every path takes it whole; nothing loops over it, for the
7882
+ // reason convertBlockViaMenu() states — a commit between two members
7883
+ // re-renders and invalidates every id in between.
7884
+ const opEls = operands.els;
7885
+ const opRecs = operands.recs;
7886
+ // ── the FIRST home position, and it is shared by every kind and size ──
7887
+ // A drop that lands where the block already is is a silent byte no-op —
7888
+ // NOT a refusal and NOT a dropped gesture. It has to be answered before
7889
+ // every gate, and MEASURED, because the gates get it wrong on their own:
7890
+ // `before-block <sourceIndex>` — released on the block's own top edge,
7891
+ // which is exactly what the drag's "released where it started" scenario
7892
+ // drives — makes the destination the SOURCE, and the destination's index
7893
+ // in the source-less array below is then -1, i.e. the
7894
+ // '文件已更新,請重試這個操作' banner, for a gesture in which nothing
7895
+ // whatsoever went wrong. Caught by driving a real gesture and reading the
7896
+ // banner back; the byte assertions could not see it, because the file is
7897
+ // untouched either way. For a SET the same question is membership: any
7898
+ // member's top edge is the set's own footprint. The li path below has a
7899
+ // SECOND home position of its own — see performListItemDrop().
7900
+ if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
7901
+ const shape = spanListKinds(opEls);
7902
+ // §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
7903
+ // A mixed span would need a blank-line rule at the run/non-run seam — the
7904
+ // li half has to stay tight while the non-li half has to keep its
7905
+ // separators, and inside ONE commit range those two demands conflict. §3.6
7906
+ // records that the spec does not have that rule and that inventing it in
7907
+ // the last stage repeats the §3.4 marker-width error, so this refuses with
7908
+ // 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
7909
+ //
7910
+ // DELIBERATELY ABOVE the second home position, which is the one place this
7911
+ // function departs from Task 3's "answer a home position before every
7912
+ // gate" rule, and the departure is the point: a seam objection depends on
7913
+ // where the user AIMED, so refusing a put-it-back gesture with one would
7914
+ // be a banner for a gesture in which nothing went wrong. MIXED is a
7915
+ // property of the SET — it gives the same answer at every destination, so
7916
+ // there is no aim that would have worked and saying so is the honest
7917
+ // outcome. (The drop-on-a-member case is already out, above.)
7918
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
7919
+ // S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
7920
+ // relocated verbatim (markers and ordinals are run-global), so the whole
7921
+ // run is re-serialized in the new span order instead — still ONE
7922
+ // commitRangeEdit, still one Ctrl+Z. `shape.allLi` rather than the first
7923
+ // member's type: a set is all-li or it is mixed, and mixed is already out.
7924
+ // Everything below this line is the non-li move.
7925
+ if (shape.allLi) {
7926
+ await performListItemDrop(opEls, liveDestEl);
7927
+ return;
7928
+ }
7929
+ // ── the SECOND home position, still before every gate ──────────────
7930
+ // `before-block <sourceIndex + 1>` — the seam immediately below, the same
7931
+ // position on screen — is a no-op planBlockMove() answers null for, but
7932
+ // the list-seam gate below would have refused it OUT LOUD first whenever
7933
+ // the block sits between two list items. (The first home position is
7934
+ // answered above, before the li branch, because both kinds share it.)
7935
+ const live = blockDropCandidates();
7936
+ // The set's own two edges. `si` is its FIRST member and `sj` its LAST —
7937
+ // the source seam is what stands OUTSIDE those, and asking `si + 1` for it
7938
+ // would name the set's second member on any set of more than one.
7939
+ const si = live.indexOf(opEls[0]);
7940
+ const sj = live.indexOf(opEls[opEls.length - 1]);
7941
+ const rest = live.filter((el) => opEls.indexOf(el) === -1);
7942
+ const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
7943
+ if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
7944
+ // §3.3's set is contiguous in block order — resolveGutterOperands()'s own
7945
+ // gate — so its members' lines plus the separators between them are ONE
7946
+ // range, which is what makes a batch move the same single commitRangeEdit
7947
+ // (and the same single Ctrl+Z) a one-block move is. See spanMoveRange().
7948
+ const srcRec = spanMoveRange(opRecs);
7949
+ const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
7950
+ if (!srcRec || (liveDestEl && !destRec)) {
7951
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
7952
+ return;
7953
+ }
7954
+ // A destination that owns no source line has an INVERTED range, and its
7955
+ // startLine names the line its OWNER occupies — inserting there would put
7956
+ // the block inside somebody else's territory.
7957
+ //
7958
+ // ⚠ S4 Task 6 MEASURED that this is REACHABLE, and that Task 3's comment
7959
+ // here ("unreachable while the seam gate below holds") was wrong. It said
7960
+ // so on the grounds that every no-line phantom this model produces is a
7961
+ // li — true, and beside the point: the seam gate refuses a destination
7962
+ // whose BOTH neighbours are li of one run, and a phantom's upper
7963
+ // neighbour is very often not a li at all. Driven as a real gesture on
7964
+ // '# Doc\n\npara\n\n- - b\n- c\n\ntail\n' (blockmap:
7965
+ // heading{1,1} | para{3,3} | li{5,4} PHANTOM | li{5,5} | li{6,6} |
7966
+ // para{8,8}): the phantom has a box of its own (measured top 190,
7967
+ // bottom 215 at 1400x900), so nearestBlockDropTarget() names it like any
7968
+ // other block; `rest[di - 1]` is the PARAGRAPH, so blockMoveSeamRefusal()
7969
+ // answers null; and planBlockMove() answers a REAL plan for it
7970
+ // ('# Doc\n\npara\n\ntail\n\n- - b\n- c\n'), so it is not a home
7971
+ // position either. Dragging `tail` onto that phantom therefore did
7972
+ // nothing, wrote nothing and said nothing — §3.6's 「靜默不動作是缺陷」,
7973
+ // and the third silent shape this task owed on top of the two the plan
7974
+ // named.
7975
+ //
7976
+ // The banner is the DESTINATION seam's, not a fifth constant: the line
7977
+ // this drop names belongs to the phantom's own nested item, so the block
7978
+ // really would land between an item and its sublist, which is exactly
7979
+ // what 無法把區塊放進清單項目之間 says. The remedy it gives (aim
7980
+ // somewhere else) is the correct one.
7981
+ if (destRec && destRec.endLine < destRec.startLine) {
7982
+ refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
7983
+ return;
7984
+ }
7985
+ // The second home position (see above): `null` means "already there".
7986
+ // Asked BEFORE the seam gate so a put-it-back gesture is silent rather
7987
+ // than refused. planBlockMove() is pure, so asking twice costs nothing.
7988
+ if (!planBlockMove(lines, blocks, srcRec, destRec)) return;
7989
+ // Both seams MEASURED with marked.lexer, not reasoned about:
7990
+ // destination — '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para`
7991
+ // spliced between the two items lexes as list(1 item) | paragraph |
7992
+ // list(1 item): ONE two-item run became TWO one-item runs, and the
7993
+ // blank lines rule 3 emits are §4.3's looseness trap;
7994
+ // source — '# Doc\n\n- a\n\npara\n\n- b\n' with `para` lifted out
7995
+ // leaves '- a\n\n- b\n', which lexes as ONE list, loose === TRUE.
7996
+ // Every item then renders as <p>, serializeBlocks() reports 'P' for
7997
+ // each, and the whole run degrades read-only WITH NO BANNER.
7998
+ // S4 Task 5 NARROWED the destination half of this and deliberately left
7999
+ // the source half wide; blockMoveSeamRefusal()'s own header carries the
8000
+ // full enumeration and the measurement behind every row of it. The short
8001
+ // version: an insertion can only SPLIT, so the destination only has to
8002
+ // refuse a seam INSIDE one run; a removal MERGES, and the model does not
8003
+ // carry the raw bytes (the bullet character, the leading two spaces) that
8004
+ // decide whether two runs about to become adjacent stay two lists.
8005
+ //
8006
+ // The descriptor is built from listRunOf() — §3.4 rule 2's scope — so a
8007
+ // nested child and its parent answer the same run key, which is what
8008
+ // stops "a paragraph dropped between an item and its own sublist" from
8009
+ // reading as a boundary between two separate lists.
8010
+ const seamDesc = (el) => {
8011
+ if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
8012
+ const r = listRunOf(el);
8013
+ return { runKey: r.length ? r[0].getAttribute('data-block-id') : null };
8014
+ };
8015
+ const seamRefusal = blockMoveSeamRefusal(
8016
+ { prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
8017
+ { prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
8018
+ if (seamRefusal === 'source') {
8019
+ refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
8020
+ return;
8021
+ }
8022
+ if (seamRefusal === 'destination') {
8023
+ refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
8024
+ return;
8025
+ }
8026
+ const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
8027
+ // commitRangeEdit()'s own unchanged-text answer, or a refused inverted
8028
+ // range. Neither pushed an op, so there is nothing to render or roll back.
8029
+ //
8030
+ // ⚠ S4 Task 6 audited this too, and corrected Task 3's claim that neither
8031
+ // is reachable. The inverted-range half is not (planBlockMove() has
8032
+ // already answered a real plan). The UNCHANGED-TEXT half is: MEASURED on
8033
+ // 'same\n\nsame\n\ntail\n' with the first paragraph moved past the
8034
+ // second, planBlockMove() answers a genuine plan and the text it produces
8035
+ // is the file byte-for-byte, because the two blocks are identical. That
8036
+ // is a real byte no-op — the document after the gesture IS the document
8037
+ // before it — so it stays silent for Task 3's own reason. A banner here
8038
+ // would tell a user that a move they can SEE succeeded had failed.
8039
+ if (!result.op) return;
8040
+ const prevLines = lines;
8041
+ lines = result.lines;
8042
+ // §3.3's collapse, declared IMMEDIATELY before the render and never
8043
+ // earlier: the moved block's range in the NEW document, which is where a
8044
+ // standing selection must end up. declareCollapse()'s own `if
8045
+ // (blockSelection)` guard is what stops this single-block gesture from
8046
+ // INVENTING a selection when none was standing.
8047
+ declareCollapse(result.newRange);
8048
+ const okRender = await safeRerenderAll();
8049
+ if (!okRender) {
8050
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
8051
+ }
8052
+ }
8053
+
8054
+ // ── S4 Task 4: the drop of a LIST ITEM, inside its own run (spec §4.5) ──
8055
+ //
8056
+ // Task 3's drop returned SILENTLY for a li. §3.6 is explicit that
8057
+ // 「靜默不動作是缺陷」, so after this function a li drag either MOVES or
8058
+ // refuses WITH A BANNER; the only silent outcome left is a home position,
8059
+ // which is Task 3's own ruling (a drop where the block already is is a byte
8060
+ // no-op, not a refusal) and is answered in two places here.
8061
+ //
8062
+ // THE SHAPE IS duplicateListItems()'s, and the recon named it as the only
8063
+ // existing path that already expresses a reorder as ONE commit:
8064
+ // * capture `presetRange` BEFORE mutating — runRangeOfBlocks() takes the
8065
+ // FIRST and LAST members of the array it is handed on the written
8066
+ // assumption that the array is in DOCUMENT order, which the reordered
8067
+ // span is not;
8068
+ // * build the REORDERED SPAN ARRAY (serializeBlocks() emits in array
8069
+ // order, and rebuilds its width/counter/type stacks as it walks — the
8070
+ // output is entirely determined by that order);
8071
+ // * mutateListRun() to move the node, with the focusout suppressed;
8072
+ // * commitListStructure(span, …, {presetRange, carryOver}), where
8073
+ // `carryOver` is bystanderCarryOver(span) — built AFTER the reorder and
8074
+ // BEFORE `blocks` is rebuilt — so every block's ORIGINAL bytes are
8075
+ // replayed by id and nothing is re-escaped ('~5px' would otherwise come
8076
+ // back '\~5px'). Only the marker and the leading columns are re-stated,
8077
+ // which is exactly §3.8's renumbering and §3.4's colDelta.
8078
+ // One commitRangeEdit inside commitListStructure() is one undo op, which is
8079
+ // §3.4's requirement for one gesture.
8080
+ //
8081
+ // `data-list-start` is a POSITION property, not an item property. It is the
8082
+ // only carrier of "marked's lexer opened a list token here" (§3.8 rule (d))
8083
+ // and serializeBlocks() resets its ordinal counter on it, so a reorder that
8084
+ // changes which item stands FIRST has to carry it across — the mirror image
8085
+ // of duplicateListItems() stripping it from a copy. MEASURED, on
8086
+ // '1. alpha / 2. bravo / 3. charlie' with charlie dragged to the head:
8087
+ // without the transfer the attribute stays on `alpha`, which is now the
8088
+ // span's SECOND block, and the run comes back
8089
+ // '1. charlie / 1. alpha / 2. bravo'.
8090
+ //
8091
+ // SCOPE — and it is exactly the task's title, "within its own run". The
8092
+ // destination must be a slot between the item's own §3.8 RUN SIBLINGS, or
8093
+ // the slot immediately past the last of them. Everything else is a
8094
+ // cross-boundary move: out of the list entirely, into a nested sublist, into
8095
+ // a different run. Task 5 owns that enumeration and the ruling it implements
8096
+ // is already made — refused with a banner in 3.0.0 — so they are refused
8097
+ // here with Task 5's own constant rather than half-implemented.
8098
+ //
8099
+ // S4 Task 6 widened `liEl` to `liEls` — §3.3's operand set, one OR MORE list
8100
+ // items in document order. The whole function is the same commit: the set is
8101
+ // contiguous in block order (resolveGutterOperands()'s gate), and `run` is a
8102
+ // contiguous slice of the document (listRunOf()'s own contract), so the
8103
+ // members occupy CONSECUTIVE slots of `run` and "move them" is one call to
8104
+ // reorderSpanRange() with a count instead of two paths.
8105
+ async function performListItemDrop(liEls, destEl) {
8106
+ const liEl = liEls[0];
8107
+ const lastLiEl = liEls[liEls.length - 1];
8108
+ // §3.4 rule 2's scope, and the span commitListStructure() re-serializes:
8109
+ // the OUTERMOST run reachable from this item plus every descendant of its
8110
+ // members. Not the item's own sibling run — a span that STARTS at indent 2
8111
+ // has no width recorded for depths 0 and 1 and would be emitted with no
8112
+ // indent prefix at all, i.e. committing it would de-nest the whole thing.
8113
+ const run = listRunOf(liEl);
8114
+ const srcIdxs = liEls.map((el) => run.indexOf(el));
8115
+ const srcIdx = srcIdxs[0];
8116
+ if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8117
+ // A member outside the grip's own commit span means the set straddles TWO
8118
+ // runs — contiguity in `blocks` does not imply one run, because two
8119
+ // adjacent runs separated by a delimiter change ('- a' / '1. b') are
8120
+ // adjacent blocks with no phantom between them. Same answer
8121
+ // convertBlockViaMenu() gives, through the same message: the commit
8122
+ // re-serializes ONE run's line range and a member outside it would be
8123
+ // rewritten by a range that does not cover it.
8124
+ if (srcIdxs.indexOf(-1) !== -1) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
8125
+ // ...and they must be CONSECUTIVE slots of it. Guaranteed today by
8126
+ // spanIsContiguous() plus listRunOf() returning a contiguous slice, and
8127
+ // checked anyway rather than asserted in prose: reorderSpanRange() takes a
8128
+ // `from` and a `count`, so a gap here would silently drag a NON-member
8129
+ // along with the set.
8130
+ for (let k = 1; k < srcIdxs.length; k++) {
8131
+ if (srcIdxs[k] !== srcIdxs[k - 1] + 1) { refuseStructuralListEdit(BATCH_GAP_MESSAGE); return; }
8132
+ }
8133
+ const all = blockDropCandidates();
8134
+ // ── the SECOND home position ───────────────────────────────────────
8135
+ // `before-block <sourceIndex + 1>` is the seam immediately below the item,
8136
+ // which is the same place on screen as its own top edge. For an item WITH
8137
+ // CHILDREN that next block is its own first child, and "before my own
8138
+ // first child" is precisely where it already is; for the run's LAST item
8139
+ // it is the block AFTER the run, which must be read as "already there"
8140
+ // and not as a boundary crossing. Answered before every gate, for Task 3's
8141
+ // reason: a refusal here would be a banner for a gesture in which nothing
8142
+ // went wrong.
8143
+ const afterSrcEl = all[all.indexOf(lastLiEl) + 1] || null;
8144
+ if (destEl ? destEl === afterSrcEl : all[all.length - 1] === lastLiEl) return;
8145
+ // ── §4.3's run-wide gate ───────────────────────────────────────────
8146
+ // 「轉換/建立副本/刪除/拖曳一律在 mutation 前走
8147
+ // listRunSupportsStructuralEdit()」. There is no shared helper — the four
8148
+ // existing call sites each make the call themselves — so this one does
8149
+ // too. Its input is §3.4 rule 2's scope, which is exactly what listRunOf()
8150
+ // returns. Ahead of every OTHER refusal, the convention convertBlockViaMenu()
8151
+ // states: a run that cannot be structurally edited at all must say so,
8152
+ // rather than report whichever narrower objection is asked next.
8153
+ //
8154
+ // `columnOnly` is the honest option, and it is the same argument
8155
+ // insertBlockBelow()'s li path and the rule-2 merge already make: a
8156
+ // reorder rewrites NO item's CONTENT and NO item's LINE COUNT. The only
8157
+ // bytes that move in any member are its marker and its leading columns —
8158
+ // §3.8's renumbering, applied to a replayed bystander as §3.4's colDelta —
8159
+ // which is the criterion listRunSupportsStructuralEdit() documents.
8160
+ // Without it a single hard-wrapped item anywhere in the run would veto
8161
+ // every drag, which on this repo's own CHANGELOG.md is every run (80.6% of
8162
+ // its list items are hard-wrapped).
8163
+ if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
8164
+ refuseStructuralListEdit();
8165
+ return;
8166
+ }
8167
+ // ── the destination must be a slot in the item's OWN §3.8 run ──────
8168
+ // `sibs` is that run: the same-depth members listRunOf() walked out from.
8169
+ // The two legal shapes are "before sibling S" and "past the last
8170
+ // sibling's whole subtree" — the latter being the ONLY way to name the
8171
+ // end of a run that something follows, since the drop target can only ever
8172
+ // name a block, and the block it names there is outside the run.
8173
+ const sibs = runBlocksOf(liEl);
8174
+ const lastSib = sibs[sibs.length - 1];
8175
+ const sibTail = subtreeBlocksAfter(lastSib, Number(lastSib.getAttribute('data-indent')) || 0);
8176
+ const runEndEl = sibTail.length ? sibTail[sibTail.length - 1] : lastSib;
8177
+ const afterRunEl = all[all.indexOf(runEndEl) + 1] || null;
8178
+ let insertAt = -1;
8179
+ if (destEl === null) {
8180
+ // The {mode:'append'} target is "after the document's last block", so it
8181
+ // names the end of this run only when the run ends the document.
8182
+ if (afterRunEl === null) insertAt = run.indexOf(runEndEl) + 1;
8183
+ } else if (sibs.indexOf(destEl) !== -1) {
8184
+ insertAt = run.indexOf(destEl);
8185
+ } else if (destEl === afterRunEl) {
8186
+ insertAt = run.indexOf(runEndEl) + 1;
8187
+ }
8188
+ // All four out-of-run shapes land here: a slot between two non-li, a
8189
+ // DIFFERENT run, the middle of a run of another list type, and the
8190
+ // {mode:'append'} target when a block follows the run. Task 5 gave them
8191
+ // their own message — the remedy is "stay inside your own list", which
8192
+ // neither seam message says.
8193
+ if (insertAt < 0) { refuseStructuralListEdit(BLOCK_MOVE_OUT_OF_RUN_MESSAGE); return; }
8194
+ // `null` is the order-is-unchanged answer — the two home positions above
8195
+ // reach this by another road (an item whose next block is a sibling), and
8196
+ // it stays SILENT for the same reason.
8197
+ const order = reorderSpanRange(run.length, srcIdx, srcIdxs.length, insertAt);
8198
+ if (!order) return;
8199
+ // Captured BEFORE the reorder, and this is the whole reason duplicateListItems()
8200
+ // captures it too: runRangeOfBlocks() reads the first and last members of
8201
+ // the ARRAY, and the reordered span's first member is not the run's first
8202
+ // line.
8203
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
8204
+ if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8205
+ const span = order.map((i) => run[i]);
8206
+ // ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
8207
+ //
8208
+ // §4.5: the moved item keeps its own `data-indent` (nothing here writes
8209
+ // one) and 「子項留在原地(依 3.4 位移夾取)」 — the children it leaves
8210
+ // behind are re-anchored by §3.4.
8211
+ //
8212
+ // THE MEASUREMENT THIS IS BUILT ON, and it is the correction of Task 4's.
8213
+ // Task 4 measured that handing `applyIndentClamp()` the REORDERED span
8214
+ // clamps nothing — true, and for a reason that names its own remedy:
8215
+ // rule 2's scope starts after the operated block's NEW index, while the
8216
+ // orphans are left behind at its OLD one. A move IS a REMOVAL at the old
8217
+ // index plus an INSERTION at the new one, so the orphans are the removal's
8218
+ // business and `{ removed: true }` — the mode the ⠿ delete has used since
8219
+ // S1 — is exactly the right question, asked of `run` in DOCUMENT order
8220
+ // (which it still is: nothing has touched the DOM yet). `clampIndents()`
8221
+ // reports no indent for a removed block, so the travelling members' own
8222
+ // depths are never rewritten by this; the reorder below then re-inserts
8223
+ // them at the destination unchanged. NOTHING in indent-clamp.js changed,
8224
+ // no new mode, no §3.4 amendment.
8225
+ //
8226
+ // Two things the sweep settled that prose could not (every legal indent
8227
+ // array of length 2..6 and depth 0..3 × every operand-set size × every
8228
+ // destination the gate above admits = 4067 drops; the pin is in
8229
+ // test/editor-client.test.js):
8230
+ //
8231
+ // 1. §4.5 also says the moved block is 「落點使其非法時夾到合法值」. That
8232
+ // has NO reachable case: 0 of the 4067 leave the moved block's own
8233
+ // indent illegal, because the destination gate above only ever admits
8234
+ // a slot among its own same-depth siblings and the block standing
8235
+ // before such a slot can always parent it. If that gate is ever
8236
+ // widened (3.1.0) this becomes owed — and rule 1 of clampIndents()
8237
+ // already answers it, on the span, at the new index.
8238
+ // 2. The clamp does not answer everything. 14 of the 4067 stay
8239
+ // unanchored, all of them BATCH sets whose last member is SHALLOWER
8240
+ // than the block they land before — the INSERTION half, at the new
8241
+ // index, for which §3.4 has no rule. Those keep Task 4's refusal,
8242
+ // narrowed to them and re-worded with it.
8243
+ //
8244
+ // ORDER MATTERS: compute, judge, and only then write. A refusal that has
8245
+ // already written `data-indent` leaves a half-clamped DOM behind on a
8246
+ // gesture that wrote no bytes — the same screen/model disagreement the
8247
+ // clamp exists to prevent, produced by the clamp itself.
8248
+ const clamp = computeIndentClamp(run, liEls, spanMinIndent(liEls), { removed: true });
8249
+ const clamped = new Map();
8250
+ (clamp || []).forEach((r) => { clamped.set(r.el, r.indent); });
8251
+ if (!spanIndentsAreAnchored(span.map((el) => ({
8252
+ type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
8253
+ indent: clamped.has(el) ? clamped.get(el)
8254
+ : (Number(el.getAttribute('data-indent')) || 0),
8255
+ })))) {
8256
+ refuseStructuralListEdit(BLOCK_MOVE_ORPHAN_MESSAGE);
8257
+ return;
8258
+ }
8259
+ // S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
8260
+ // measurement to show it. Deleting the line left all 39 S4 scenarios green
8261
+ // — the four T7 ones named after the clamp included — because every one of
8262
+ // them moves a SINGLE item, and for `count === 1` the write can never
8263
+ // change a byte: serializeBlocks() rebuilds its width stack as it walks
8264
+ // (`widths.length = indent + 1` after every block), so an over-deep
8265
+ // `data-indent` is emitted at its anchor's own column, which is the column
8266
+ // the clamp would have written. Re-run over the whole 4067-drop space with
8267
+ // the real serializeBlocks() on both sides: 0 single-item drops differ, 53
8268
+ // BATCH drops differ on a `ul` and 123 on an `ol`. The smallest is indents
8269
+ // 0,0,1,2,1 with {1,2} moved past the run — without the write the orphan
8270
+ // that kept its departed parent's depth is ADOPTED by a block the user
8271
+ // never touched, which is rule 3's own worked failure. Pinned as bytes by
8272
+ // the RV2 runtime scenario and as counts in test/editor-client.test.js.
8273
+ writeIndentClamp(clamp);
8274
+ const sibsAfter = span.filter((el) => sibs.indexOf(el) !== -1);
8275
+ mutateListRun(() => {
8276
+ // `data-list-start` follows the run's HEAD, not the item — see this
8277
+ // function's own header for the measured '1. charlie / 1. alpha' that
8278
+ // dropping this produces. Only the moved item can change which sibling
8279
+ // stands first, so this is the only pair that can need it; a NESTED
8280
+ // run's own list-start belongs to a member nothing moved and is left
8281
+ // exactly where it is.
8282
+ if (sibsAfter[0] !== sibs[0] && sibs[0].getAttribute('data-list-start') === '1') {
8283
+ sibs[0].removeAttribute('data-list-start');
8284
+ sibsAfter[0].setAttribute('data-list-start', '1');
8285
+ }
8286
+ // `insertAt` indexes `run`, which is still in document order at this
8287
+ // point — the DOM has not been touched yet. `insertAt === run.length`
8288
+ // cannot be the moved item's own slot (that is the home position, out
8289
+ // above), so the reference node is safe to read off the run's last
8290
+ // member.
8291
+ const refEl = insertAt < run.length ? run[insertAt] : run[run.length - 1].nextSibling;
8292
+ // In the set's OWN order, each before the same reference node: that
8293
+ // leaves them consecutive and in document order at the destination,
8294
+ // which is what reorderSpanRange() already said the span would be. A
8295
+ // loop that inserted each before the PREVIOUS one would reverse the set.
8296
+ liEls.forEach((el) => { el.parentNode.insertBefore(el, refEl); });
8297
+ });
8298
+ // AFTER the reorder and BEFORE `blocks` is rebuilt, which is the window
8299
+ // bystanderCarryOver() documents. No `mutatedEl`: nothing in this span had
8300
+ // its CONTENT rewritten — the map is what keeps every member byte-
8301
+ // identical to the file, the moved one included.
8302
+ const carryOver = bystanderCarryOver(span);
8303
+ // §3.3's collapse, declared immediately before the render inside
8304
+ // commitListStructure(). declareCollapse()'s own `if (blockSelection)`
8305
+ // guard is what stops this single-block gesture from INVENTING a
8306
+ // selection when none was standing; when one WAS, resolveGutterOperands()
8307
+ // has already collapsed it onto this item, so it has to follow the item
8308
+ // to its new lines.
8309
+ declareCollapse(movedLiRangeAfterReorder(span, liEls, carryOver, range));
8310
+ await commitListStructure(span, null, false,
8311
+ { presetRange: range, carryOver: carryOver });
8312
+ }
8313
+
8314
+ // The moved item's line range in the run AS IT IS ABOUT TO BE COMMITTED.
8315
+ //
8316
+ // Deliberately NOT runLineOfBlock(), and this is a measurement that
8317
+ // contradicts the obvious reuse: that function derives the run's start line
8318
+ // with runRangeOfBlocks(runEls), which takes the FIRST member of the array —
8319
+ // correct for every existing caller, whose array is in document order, and
8320
+ // wrong here by construction, because the reordered span's first member is
8321
+ // whichever item was just moved to the head. The rest of its arithmetic is
8322
+ // reproduced: `lineMeta` carries one entry per EMITTED line naming the block
8323
+ // that owns it, so a block's range is the run of entries bearing its id —
8324
+ // counted, never assumed to be one, because a hard-wrapped item replays
8325
+ // several.
8326
+ //
8327
+ // S4 Task 6: `liEls` is §3.3's operand set, one or more. The members travel
8328
+ // together and stay consecutive, so their emitted lines are one contiguous
8329
+ // run and the answer is the FIRST line of the first member to the LAST line
8330
+ // of the last — which is exactly §3.3's 「操作結果所涵蓋的行區間」. Taking
8331
+ // the first member's range alone would collapse a three-block drag onto one
8332
+ // tinted block.
8333
+ function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
8334
+ const ids = liEls.map((el) => el.getAttribute('data-block-id'));
8335
+ if (ids.indexOf(null) !== -1) return null;
8336
+ const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
8337
+ let first = -1;
8338
+ let last = -1;
8339
+ lineMeta.forEach((m, i) => {
8340
+ if (ids.indexOf(m.blockId) === -1) return;
8341
+ if (first < 0) first = i;
8342
+ last = i;
8343
+ });
8344
+ if (first < 0) return null;
8345
+ return { startLine: range.startLine + first, endLine: range.startLine + last };
8346
+ }
8347
+
8348
+ // Unconditional teardown of the in-flight ⠿ gesture — called from FIVE
8349
+ // places, exactly mirroring cancelTeDrag()'s own list: the Escape
8350
+ // prologue's first rungs, `pointercancel`, window `blur`, the defensive
8351
+ // clear at the top of the next `pointerdown`, and every re-render path
8352
+ // (rerenderAll() / resolveBurst()). Nothing was mutated, so there is
8353
+ // nothing to revert and no state this is unsafe to be called from — the
8354
+ // null-out is unconditional on `blockDragState` being set and is NOT gated
8355
+ // on `.dragging`, which is the review fix cancelTeDrag() carries too (a
8356
+ // burst resolution landing during the pressed-but-pre-threshold window used
8357
+ // to leave the table's state pointing at a row rerenderAll() was about to
8358
+ // detach).
8359
+ function cancelBlockDrag() {
8360
+ teardownBlockDrag();
8361
+ }
8362
+
5917
8363
  document.addEventListener('pointerdown', (e) => {
5918
8364
  if (e.button !== 0) return;
5919
8365
  if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
@@ -5924,6 +8370,16 @@
5924
8370
  // capture) BEFORE starting a new one, so a stuck drag can never survive
5925
8371
  // into the next gesture and a fresh pointerdown always starts clean.
5926
8372
  if (tePointer) cancelTeDrag();
8373
+ // S4 Task 2: same defensive reasoning again for the ⠿ block drag — a prior
8374
+ // gesture whose pointerup/pointercancel never arrived must not survive into
8375
+ // this one. Nothing was mutated, so this is always a clean no-op teardown.
8376
+ if (blockDragState) cancelBlockDrag();
8377
+ // S3 Task 4: same defensive reasoning one line up, for the block-selection
8378
+ // press — and the flag that suppresses a gesture's own trailing click is
8379
+ // re-armed here so a gesture whose click never arrived cannot swallow the
8380
+ // next one.
8381
+ blockSelClickSuppressed = false;
8382
+ if (blockSelDrag) endBlockSelDrag();
5927
8383
  const hit = hitTestGrip(e.target);
5928
8384
  // A click on a DIFFERENT zone (or entirely outside any zone) dismisses
5929
8385
  // whatever menu is already open, same "any other click closes the ⠿
@@ -5939,7 +8395,38 @@
5939
8395
  // teMenuKind alone, or that highlight would survive until
5940
8396
  // resolveBurst() instead of clearing on the next click.
5941
8397
  if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
5942
- if (!hit) return;
8398
+ if (!hit) {
8399
+ // S3 Task 4 (recon hazard 1): this listener already fires on EVERY left
8400
+ // click and is a no-op whenever hitTestGrip() finds nothing — which is
8401
+ // exactly the branch the block-selection gestures belong on. Arming
8402
+ // them HERE, inside the incumbent handler, is what makes "a grip hit
8403
+ // still wins" true by construction: a second, competing pointerdown
8404
+ // listener would have to re-derive the hit test, and would race this
8405
+ // one's menu dismiss above depending on registration order.
8406
+ // S4 Task 2 (spec §4.5): the ⠿ drag arms HERE, ahead of both selection
8407
+ // entries, and claims the pointer when it owns it. It has to run before
8408
+ // armBlockSelDrag(), whose first line returns for `.ed-handle` (a member
8409
+ // of ED_SEL_GESTURE_CHROME) — which is why a press on the ⠿ recorded no
8410
+ // pointer state at all before this task. beginShiftClickSelection()
8411
+ // returns for the same target, so ordering it above the Shift branch too
8412
+ // changes nothing for any press that is not on a ⠿: armBlockDrag()
8413
+ // returns false for every other target and falls straight through.
8414
+ //
8415
+ // S4 review round (2026-09-01) — RECORDED, because nobody had. It DOES
8416
+ // change one thing for a press that IS on a ⠿: armBlockDrag() reads no
8417
+ // modifier, so Shift / Ctrl / Alt + ⠿ now starts a drag, where before S4
8418
+ // it did nothing at all until release. KEPT, deliberately: the table's
8419
+ // row and column grips take the pointer with no modifier check either
8420
+ // (see the `if (!hit)` branch's else, below), so a ⠿ that behaved
8421
+ // differently would be the surprise; neither §3.3 nor §4.5 gives a
8422
+ // modified ⠿ press a meaning of its own; and a modified press that never
8423
+ // crosses the threshold still opens the menu, so nothing was taken away.
8424
+ // Pinned by the RV5 scenario in test/editor-client-runtime.test.js.
8425
+ if (armBlockDrag(e)) return;
8426
+ if (e.shiftKey) { beginShiftClickSelection(e); return; }
8427
+ armBlockSelDrag(e);
8428
+ return;
8429
+ }
5943
8430
  e.preventDefault();
5944
8431
  tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
5945
8432
  pointerId: e.pointerId, captureEl: e.target };
@@ -5989,7 +8476,38 @@
5989
8476
  else updateColDropIndicator(e.clientX);
5990
8477
  });
5991
8478
 
8479
+ // S3 Task 4: the block-selection drag's own pointermove. Registered after
8480
+ // the table drag's (above), and bails while `tePointer` is set, so a grip
8481
+ // gesture is never fought over. Not folded into that listener because its
8482
+ // very first line is `if (!tePointer) return;` — the state this one runs in.
8483
+ document.addEventListener('pointermove', (e) => {
8484
+ updateBlockSelDrag(e);
8485
+ });
8486
+
8487
+ // S4 Task 2: the ⠿ block drag's own pointermove. Registered alongside the
8488
+ // other two rather than folded into either: the table drag's listener bails
8489
+ // on `!tePointer` (the state this one runs in) and the selection drag's bails
8490
+ // on `!blockSelDrag` (likewise). updateBlockDrag()'s own first lines are the
8491
+ // symmetric bail.
8492
+ document.addEventListener('pointermove', (e) => {
8493
+ updateBlockDrag(e);
8494
+ });
8495
+
5992
8496
  document.addEventListener('pointerup', async (e) => {
8497
+ // S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
8498
+ // this file, so the block-selection drag ends on the pointer events, the
8499
+ // same skeleton the table drag uses. Before the `!tePointer` bail: a
8500
+ // selection drag is armed precisely when no grip gesture is in flight.
8501
+ endBlockSelDrag();
8502
+ // S4 Task 2: and the ⠿ block drag's. Same placement reasoning as the line
8503
+ // above — a block drag is armed precisely when no grip gesture is in
8504
+ // flight, so it must be ended before the `!tePointer` bail.
8505
+ // S4 Task 3: AWAITED, now that the drop writes. endBlockDrag()'s own
8506
+ // teardown runs synchronously (an async function body runs to its first
8507
+ // await), so the `tePointer` branch below is not reordered around any
8508
+ // state it reads; what the await buys is that the commit and its
8509
+ // re-render are complete before this handler returns.
8510
+ await endBlockDrag();
5993
8511
  if (!tePointer) return;
5994
8512
  const st = tePointer;
5995
8513
  releaseTeCapture(st);
@@ -6035,6 +8553,14 @@
6035
8553
  // actually moved mid-drag, only the indicator line).
6036
8554
  document.addEventListener('pointercancel', () => {
6037
8555
  cancelTeDrag();
8556
+ // S4 Task 2: the same abort ends a ⠿ block drag. Nothing was mutated (only
8557
+ // the indicator moved), so this is the same unconditional cleanup.
8558
+ cancelBlockDrag();
8559
+ // S3 Task 4: the same abort ends a block-selection drag. The selection it
8560
+ // has built so far STANDS (§4.4: nothing but Escape and a plain click
8561
+ // clears) — what must not survive is the live drag, or the next stray
8562
+ // pointer move would keep extending a gesture the user already ended.
8563
+ endBlockSelDrag();
6038
8564
  });
6039
8565
 
6040
8566
  // Review fix (Critical): the whole BROWSER WINDOW losing focus mid-
@@ -6046,6 +8572,13 @@
6046
8572
  // when no gesture is in flight, so this is safe to fire on every blur.
6047
8573
  window.addEventListener('blur', () => {
6048
8574
  cancelTeDrag();
8575
+ // S4 Task 2: same again for the ⠿ block drag — an undeliverable pointerup
8576
+ // is an undeliverable pointerup whichever gesture is holding it.
8577
+ cancelBlockDrag();
8578
+ // S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
8579
+ // drag in flight when focus left is exactly as undeliverable-pointerup as
8580
+ // a row drag's, so it is torn down here too.
8581
+ endBlockSelDrag();
6049
8582
  });
6050
8583
 
6051
8584
  // ── Phase-2 Task 4: floating selection toolbar (bold/italic/code/link) ──
@@ -6386,6 +8919,12 @@
6386
8919
  (e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
6387
8920
  });
6388
8921
  document.addEventListener('click', async (e) => {
8922
+ // S3 Task 4: the trailing click of a gesture that already answered this
8923
+ // press itself (a drag across a block boundary, a Shift+Click). A drag
8924
+ // released in another block would otherwise hit the §4.4 exit rule
8925
+ // below and clear the set it just built; one released on the page
8926
+ // margin would hit the outside-a-block switchAwayFrom() instead.
8927
+ if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
6389
8928
  if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); closeInsertMenu(); return; }
6390
8929
  // showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
6391
8930
  // .ed-block — so without this guard a click on the banner's own
@@ -6489,6 +9028,15 @@
6489
9028
  // Any other click closes an already-open ⠿ menu / + menu.
6490
9029
  if (gutterMenuBlockEl) closeGutterMenu();
6491
9030
  if (insertMenuBlockEl) closeInsertMenu();
9031
+ // S3 Task 4, §4.4 exit: a click INSIDE any block, without Shift, clears
9032
+ // the whole set. Deliberately below the ⠿/+/menu/checkbox branches
9033
+ // above, which all return early — §3.3 decides batch-vs-single by
9034
+ // whether the GRIP is inside the set, so a grip click must not clear it
9035
+ // first. A click OUTSIDE every block is deliberately not an exit: §4.4
9036
+ // lists exactly Escape and this one. clearBlockSelection() steals no
9037
+ // focus (Task 3 carry 7), so the caret still lands where the user
9038
+ // clicked.
9039
+ if (blockSelection && !e.shiftKey && e.target.closest('.ed-block')) clearBlockSelection();
6492
9040
  if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
6493
9041
  let blockEl = e.target.closest('.ed-block');
6494
9042
  // v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
@@ -6589,6 +9137,14 @@
6589
9137
  const r = stack.undo(lines);
6590
9138
  if (!r) return;
6591
9139
  lines = r.lines;
9140
+ // S3 Task 5 (§4.4): undo/redo ALWAYS clears the block selection.
9141
+ // `UndoStack`'s op is exactly {startLine, endLine, before, after}
9142
+ // (lineops.js) and carries no selection state, so there is nothing to
9143
+ // restore a set to — and Task 5's rebuild would otherwise keep the old
9144
+ // line range standing over a document that just changed underneath it.
9145
+ // DECLARED rather than cleared outright so the rollback below leaves a
9146
+ // standing selection alone when the render fails.
9147
+ declareSelectionRange(null);
6592
9148
  const ok = await safeRerenderAll();
6593
9149
  if (!ok) {
6594
9150
  // Reverse the undo attempt: push the op back and restore `lines` to
@@ -6604,6 +9160,7 @@
6604
9160
  const r = stack.redo(lines);
6605
9161
  if (!r) return;
6606
9162
  lines = r.lines;
9163
+ declareSelectionRange(null); // §4.4, same as undo() above
6607
9164
  const ok = await safeRerenderAll();
6608
9165
  if (!ok) {
6609
9166
  // Reverse the redo attempt: pop the op back off and restore `lines`.
@@ -6614,36 +9171,71 @@
6614
9171
 
6615
9172
  // ── global key handling ─────────────────────────────────────────────────
6616
9173
  document.addEventListener('keydown', (e) => {
6617
- // Task 6: Esc during an in-flight row drag cancels JUST the drag (no
6618
- // mutation — the row was never actually moved, only the indicator line
6619
- // tracked the pointer) — a DISTINCT gesture from Esc-reverts-burst
6620
- // (handleTableCellKeydown() below), which must never fire for this same
6621
- // keypress. Checked before EVERY other branch (including Ctrl+S) so a
6622
- // drag in flight always wins the keystroke.
6623
- if (tePointer && tePointer.dragging && e.key === 'Escape') {
6624
- e.preventDefault();
6625
- cancelTeDrag();
6626
- return;
6627
- }
6628
- // Task 6: Esc with the edge menu open closes JUST the menu — same
6629
- // "intercept before the wys-cell/wys-armed Escape branches" reasoning,
6630
- // since the cell that was focused (if any) before the menu opened is
6631
- // still focused underneath it (the menu's own mousedown preventDefault()
6632
- // never stole focus).
6633
- // Final review I1: the condition is `(teMenuKind || teHighlightEls.length)`,
6634
- // NOT `teMenuKind` alone — the exact same widening the pointerdown dismiss
6635
- // gate already got, and for the same reason: a header grip's click
6636
- // deliberately produces a highlight with NO menu (its only menu item,
6637
- // "delete row", cannot apply to a header), so teMenuKind stays null. Gated
6638
- // on teMenuKind alone, that Esc fell through to handleTableCellKeydown()'s
6639
- // own Escape branch -> revertTableBurstAndEnd(), throwing away everything
6640
- // typed into the burst. With a BODY row's menu open the identical keypress
6641
- // merely closes the menu, so the header row would have been the one place
6642
- // where dismissing a selection is destructive.
6643
- if ((teMenuKind || teHighlightEls.length) && e.key === 'Escape') {
6644
- e.preventDefault();
6645
- hideTableEdgeMenu();
6646
- return;
9174
+ // ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
9175
+ // A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
9176
+ // explicit about that). The two target-based short-circuits further down
9177
+ // (.ed-wys-cell, .ed-wys-armed) `return` for EVERY key, so the gutter /
9178
+ // insert menu's own Escape branch — which sits BELOW them — was
9179
+ // unreachable whenever an armed surface held focus. And it always does
9180
+ // while a ⠿ menu is open: wireBlockSelection()'s mousedown
9181
+ // preventDefault() deliberately keeps focus inside the burst so a dirty
9182
+ // block's own ⠿ click cannot race its blur-commit.
9183
+ //
9184
+ // Measured on 2026-08-30 against v2.11.0: typing " EDITED" into a
9185
+ // paragraph, opening its ⠿ menu and pressing Escape reached
9186
+ // handleBurstKeydown() -> revertBurstAndEnd() — the paragraph went back
9187
+ // to "alpha", the uncommitted edit was destroyed, AND the menu stayed on
9188
+ // screen. Exactly inverted from what the user asked for.
9189
+ //
9190
+ // ⚠ CORRECTED 2026-08-31 (review recommendation 6). Until this date the
9191
+ // comment here read "the two Escape branches immediately below ... are
9192
+ // deliberately kept: they still carry their non-Escape duties". They had
9193
+ // none — both were gated on `&& e.key === 'Escape'` and both conditions
9194
+ // are re-asked verbatim in this prologue, which returns, so neither could
9195
+ // ever run again. They have been DELETED and the reasoning they carried
9196
+ // is folded in here, which is where the live checks are:
9197
+ //
9198
+ // * the row drag wins the keystroke ahead of everything else, Ctrl+S
9199
+ // included, so a drag in flight is never resolved by a save;
9200
+ // * the edge-menu condition is `(teMenuKind || teHighlightEls.length)`,
9201
+ // NOT `teMenuKind` alone. That widening was made in S0 (final review
9202
+ // I1) for the HEADER grip, whose click deliberately produces a
9203
+ // highlight with no menu (its only item, "delete row", cannot apply to
9204
+ // a header), so teMenuKind stays null. Gated on teMenuKind alone the
9205
+ // Escape fell through to handleTableCellKeydown()'s own Escape branch
9206
+ // -> revertTableBurstAndEnd(), throwing away everything typed into the
9207
+ // burst — so the header row would have been the one place where
9208
+ // dismissing a selection is destructive. Do not narrow it back.
9209
+ //
9210
+ // The `closeGutterMenu()` source-presence guard in
9211
+ // test/editor-client.test.js is NOT affected: it matches the literal
9212
+ // `e.key === 'Escape') { e.preventDefault(); closeGutterMenu();`, which is
9213
+ // the THIRD Escape branch, much further down in this same listener, and
9214
+ // that one is still live for a keystroke this prologue lets through.
9215
+ //
9216
+ // Placement note: this sits ABOVE the `.ed-raw` textarea bail below, which
9217
+ // is where the two now-deleted Escape branches sat too. Harmless for the raw
9218
+ // editor: its own per-instance keydown listener runs in the TARGET phase,
9219
+ // before this document-level one, so its Escape has already been handled;
9220
+ // and the one menu item that opens a raw editor (MD 原始碼) calls
9221
+ // closeGutterMenu() first, so gutterMenuBlockEl is null by then.
9222
+ if (e.key === 'Escape') {
9223
+ if (tePointer && tePointer.dragging) { e.preventDefault(); cancelTeDrag(); return; }
9224
+ // S4 Task 2: the ⠿ block drag gets the same first rung, and for the same
9225
+ // reason — Escape-during-a-drag is a distinct gesture from
9226
+ // Escape-reverts-the-burst, and the drag must be the one that answers.
9227
+ // Mutually exclusive with the rung above (one pointer, one gesture).
9228
+ if (blockDragState && blockDragState.dragging) { e.preventDefault(); cancelBlockDrag(); return; }
9229
+ if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
9230
+ if (gutterMenuBlockEl || insertMenuBlockEl) {
9231
+ e.preventDefault();
9232
+ closeGutterMenu();
9233
+ closeInsertMenu();
9234
+ return;
9235
+ }
9236
+ if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
9237
+ // Nothing above owns this Escape — fall through to the burst
9238
+ // short-circuits below, where Escape-reverts-the-burst still belongs.
6647
9239
  }
6648
9240
 
6649
9241
  if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's') {
@@ -6672,8 +9264,30 @@
6672
9264
  // heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
6673
9265
  // cell without ending the burst) — handleTableCellKeydown() owns that
6674
9266
  // entire surface, mirroring handleBurstKeydown() just below.
9267
+ // S3: undo/redo is the one pair of keys the two short-circuits below must
9268
+ // NOT claim when there is no burst to own them. resolveBurst()'s zero-edit
9269
+ // path calls endBurstWithoutResolve() and returns WITHOUT re-rendering, so
9270
+ // after a Ctrl+S on an untouched surface `currentBurst` is null while that
9271
+ // surface still holds native focus and its .ed-wys-armed / .ed-wys-cell
9272
+ // class. The next Ctrl+Z then matched a short-circuit, reached
9273
+ // handleBurstKeydown() / handleTableCellKeydown(), and died on their
9274
+ // identical `!currentBurst` bail on the first line — the global undo()
9275
+ // further down was never reached.
9276
+ //
9277
+ // Measured on 2026-08-30 against v2.11.0, for paragraph, li AND table:
9278
+ // type a character, Ctrl+S, click back in, Ctrl+S again (an ordinary habit
9279
+ // keystroke), Ctrl+Z — the file did not change. Pre-existing on main;
9280
+ // fixed here because this is the same dispatch S3 rewrites.
9281
+ //
9282
+ // Deliberately narrow: only the undo/redo keys, and only with no burst.
9283
+ // Every other key on an armed surface keeps going to its burst handler
9284
+ // exactly as before, burst or no burst.
9285
+ const undoRedoKey = (e.ctrlKey || e.metaKey) &&
9286
+ (e.key === 'z' || e.key === 'y' || (e.shiftKey && e.key === 'Z'));
9287
+ const burstOwnsKey = !!currentBurst || !undoRedoKey;
9288
+
6675
9289
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
6676
- if (cellEl) {
9290
+ if (cellEl && burstOwnsKey) {
6677
9291
  handleTableCellKeydown(e, cellEl);
6678
9292
  return;
6679
9293
  }
@@ -6684,11 +9298,55 @@
6684
9298
  // preventDefault() for those keys), so nothing below this must run for
6685
9299
  // it either.
6686
9300
  const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
6687
- if (wysArmedEl) {
9301
+ if (wysArmedEl && burstOwnsKey) {
6688
9302
  handleBurstKeydown(e, wysArmedEl);
6689
9303
  return;
6690
9304
  }
6691
9305
 
9306
+ // S3 Task 4, §4.4 entry (c). Deliberately BELOW the two burst
9307
+ // short-circuits above: inside an armed surface (or a table cell)
9308
+ // Shift+↑↓ is the browser's own extend-the-text-selection gesture and
9309
+ // must stay that way. A standing selection's focus holder is a plain
9310
+ // `.ed-block` — neither `.ed-wys-armed` nor `.ed-wys-cell` — so the keys
9311
+ // reach here exactly when block selection is what they can mean.
9312
+ if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
9313
+ (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
9314
+ if (stepSelectionFocus(e.key === 'ArrowUp' ? -1 : 1)) { e.preventDefault(); return; }
9315
+ }
9316
+
9317
+ // ── S3 Task 7: §3.5's 選取集合語意 and §3.6's 「Delete 整批刪」 ─────────
9318
+ // Deliberately BELOW the two burst short-circuits, exactly like the
9319
+ // Shift+↑↓ branch above: inside an armed surface Tab indents the caret's
9320
+ // own item and Delete removes a CHARACTER, and inside a table cell Tab
9321
+ // navigates cells — three contracts this must not touch. A standing
9322
+ // selection puts DOM focus on a plain `.ed-block` wrapper, which is
9323
+ // neither `.ed-wys-armed` nor `.ed-wys-cell`, so these keys arrive here
9324
+ // exactly when block selection is what they can mean.
9325
+ //
9326
+ // `blockSelection` is the gate, and it is the whole guard: with no set
9327
+ // standing there is no focus holder either, and every key below behaves
9328
+ // exactly as it did pre-S3.
9329
+ if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey) {
9330
+ if (e.key === 'Tab') {
9331
+ // §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
9332
+ // 瀏覽器焦點巡覽.
9333
+ e.preventDefault();
9334
+ tabSelection(e.shiftKey ? -1 : 1);
9335
+ return;
9336
+ }
9337
+ // Backspace is the same gesture as Delete, deliberately: with a set
9338
+ // standing, focus is on a block wrapper and not on any text surface, so
9339
+ // neither key can mean "delete a character" — and a user who selected
9340
+ // blocks and reached for Backspace meant the selection. preventDefault()
9341
+ // matters for its own reason here: an unhandled Backspace outside an
9342
+ // editable is history-navigation in some configurations.
9343
+ if (e.key === 'Delete' || e.key === 'Backspace') {
9344
+ e.preventDefault();
9345
+ deleteSelection();
9346
+ return;
9347
+ }
9348
+ }
9349
+
6692
9350
  // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
6693
9351
  // branch above is keyed on the event target being some edit surface, and
6694
9352
  // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
@@ -6931,6 +9589,32 @@
6931
9589
  else rowGrip.hidden = true;
6932
9590
  return;
6933
9591
  }
9592
+ // S3 Task 4 (recon hazard 3): a block-selection drag needs the same
9593
+ // gate, for the same reason — this listener keeps firing every frame
9594
+ // regardless, and would repaint the + bubbles and reposition the grips
9595
+ // onto whatever row the cursor is dragging across, on top of the tint.
9596
+ // Both are HIDDEN rather than merely left un-recomputed, so anything
9597
+ // already showing from the frame before the boundary was crossed does
9598
+ // not linger stale for the rest of the gesture.
9599
+ if (blockSelDrag && blockSelDrag.dragging) {
9600
+ hideTableInsertBubbles();
9601
+ hideTableGrips();
9602
+ return;
9603
+ }
9604
+ // S4 Task 2: and a third, in the same shape, for the ⠿ block drag —
9605
+ // without it this listener would keep repainting the + bubbles and
9606
+ // repositioning the grips onto whatever row the cursor is dragging
9607
+ // across, on top of the drop indicator, every frame of the gesture. Both
9608
+ // are HIDDEN rather than merely left un-recomputed, so anything already
9609
+ // showing from the frame before the threshold was crossed cannot linger
9610
+ // stale. Unlike the table gate above there is no "active grip" to spare:
9611
+ // the drag's own handle is a per-block button that the pointer has
9612
+ // already left, and the indicator is the whole of the feedback.
9613
+ if (blockDragState && blockDragState.dragging) {
9614
+ hideTableInsertBubbles();
9615
+ hideTableGrips();
9616
+ return;
9617
+ }
6934
9618
  updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
6935
9619
  updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
6936
9620
  });