@helping-ai-workflow/md2doc 2.12.0 → 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
 
@@ -635,6 +920,11 @@
635
920
  // ago was just destroyed by the innerHTML replacement above.
636
921
  hideTableEdgeMenu();
637
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();
638
928
  // Final-review Finding 5b (Important): same belt-and-braces reasoning
639
929
  // again for the ⠿ gutter menu — `toggleGutterMenu()` appends the ONE
640
930
  // shared `gutterMenu` node as a CHILD of whichever block it's open for
@@ -1357,29 +1647,56 @@
1357
1647
  // computes it with spanMinIndent(). A member that is not in `spanEls` aborts
1358
1648
  // the whole clamp rather than clamping a subset — a partial op set makes rule
1359
1649
  // 2's scope start in the wrong place, which is a silent wrong answer.
1360
- function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1361
- if (!indentClamp || !spanEls || !spanEls.length) return;
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;
1362
1670
  const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
1363
1671
  const opIndex = [];
1364
1672
  for (let k = 0; k < opEls.length; k++) {
1365
1673
  const at = spanEls.indexOf(opEls[k]);
1366
- if (at < 0) return;
1674
+ if (at < 0) return null;
1367
1675
  opIndex.push(at);
1368
1676
  }
1369
- if (!opIndex.length) return;
1677
+ if (!opIndex.length) return null;
1370
1678
  const model = spanEls.map((el, i) => ({
1371
1679
  id: i, // index-as-id: the span IS the universe here
1372
1680
  type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
1373
1681
  indent: Number(el.getAttribute('data-indent')) || 0,
1374
1682
  }));
1375
- indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {}).forEach((r) => {
1376
- const el = spanEls[r.blockId];
1377
- if (el && (Number(el.getAttribute('data-indent')) || 0) !== r.indent) {
1378
- 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);
1379
1692
  }
1380
1693
  });
1381
1694
  }
1382
1695
 
1696
+ function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1697
+ writeIndentClamp(computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts));
1698
+ }
1699
+
1383
1700
  function placeCaretAtEnd(el) {
1384
1701
  const range = document.createRange();
1385
1702
  range.selectNodeContents(el);
@@ -2558,6 +2875,48 @@
2558
2875
  const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
2559
2876
  const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
2560
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 = '落點的子項目會失去上層項目,無法搬移到這裡';
2561
2920
  // 轉換成's own refusals are NOT declared here. They are per block TYPE, and
2562
2921
  // they are the same ruling §3.7 applies to a single block's ⠿ — so they live
2563
2922
  // with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
@@ -3641,6 +4000,10 @@
3641
4000
  hideTableGrips();
3642
4001
  hideTableEdgeMenu();
3643
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();
3644
4007
  if (!burst) return true;
3645
4008
  // §10-gap fix (review): this burst's block's "pristine" window (see
3646
4009
  // `pristineInsert`'s own comment) closes right here, the moment its
@@ -6229,8 +6592,9 @@
6229
6592
  // grips below are the fix: real, adequately-sized (≥18×24px) elements the
6230
6593
  // user can actually see and aim for. Both grips are overlay elements
6231
6594
  // `position: fixed`-appended to document.body — never DOM CHILDREN of a
6232
- // contenteditable cell, even though the row grip's inner half visually
6233
- // 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
6234
6598
  // note in the "Notion-style row/column grip handles" section below). So —
6235
6599
  // unlike the old zones, which
6236
6600
  // sat INSIDE an already-permanently-contenteditable cell and needed the
@@ -6543,20 +6907,46 @@
6543
6907
  // listener (wired near the bottom of this file) that already drives
6544
6908
  // updateTableInsertBubbles() — see its own comment for the coalescing
6545
6909
  // contract this reuses.
6546
- // Review fix (P0-a) + user acceptance (uniform geometry): neither grip is
6547
- // separated from the table, and both use the SAME rule — the grip's own
6548
- // CENTRELINE coincides with the table border it belongs to, so its hit
6549
- // rect straddles that border by half its own size on each side. COLUMN
6550
- // grip: centred on the table's TOP edge. ROW grip: centred on the table's
6551
- // LEFT edge. An earlier revision insetted the row grip fully INSIDE the
6552
- // table to dodge the block's own gutter ⠿; that was reverted — the ⠿
6553
- // occupies only the block's top ~20px while a row grip sits at its own
6554
- // row's mid-height, so the two never actually intersect, and the inset put
6555
- // the grip on top of the first cell's TEXT (user-acceptance defect). The
6556
- // gutter is given its own room in CSS instead (`.content { padding-left }`
6557
- // plus `.ed-handle/.ed-insert { left: -36px }`, both edit-mode-only — see
6558
- // lib/md2doc.js), so the straddling grip reaches only into the first
6559
- // 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.
6560
6950
  // Either way the grip's hit rect DOES overlap the insert bubble's hit
6561
6951
  // rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
6562
6952
  // own axis). Non-intersection via rect separation is no longer possible
@@ -6635,12 +7025,16 @@
6635
7025
  // gripCenter(), which jump straight to the grip's own coordinates) could
6636
7026
  // ever land on it; a real mouse gesture could not.
6637
7027
  //
6638
- // Current geometry (uniform, both axes): BOTH grips straddle their own
6639
- // table border — the row grip half outside / half inside the table's LEFT
6640
- // border, the column grip half above / half below its TOP border. So the
6641
- // corridor-crossing bug described above applies to BOTH of them exactly as
6642
- // originally described, and both keep-zones below cover the corresponding
6643
- // 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.
6644
7038
  //
6645
7039
  // Review fix (Important, first pass over-permissive): the first version of
6646
7040
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -6653,14 +7047,30 @@
6653
7047
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
6654
7048
  // pointInColGripZone() below) instead of the whole table.
6655
7049
  //
6656
- // What each keep-zone covers TODAY: both keep the ORIGINAL corridor shape,
6657
- // mirrored per axis. pointInRowGripZone() is the union of (the row grip's
6658
- // own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel rounding) and (the
6659
- // straight strip between the grip's own LEFT edge and the table's LEFT
6660
- // border, y clamped to the anchor ROW's own vertical extent, padded).
6661
- // pointInColGripZone() is the same with the axes swapped: the strip
6662
- // between the grip's own top edge and the table's top border, x clamped to
6663
- // 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.
6664
7074
  // A pointer outside either grip's own zone is a genuine exit and still
6665
7075
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
6666
7076
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -6680,15 +7090,17 @@
6680
7090
  function pointInRowGripZone(x, y) {
6681
7091
  if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
6682
7092
  !document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
6683
- const gr = rowGrip.getBoundingClientRect();
6684
- if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
6685
- // grip 跨在表格左邊界上,所以「從儲存格走向 grip」必定經過邊界外側的
6686
- // 那半個 grip 寬度。走廊=從 grip 自己的左緣到表格左緣,垂直方向夾在
6687
- // 所錨定那一列的上下緣(加 TE_GRIP_ZONE_PAD_PX 的次像素寬容)。
6688
- const rowRect = gripRowEl.getBoundingClientRect();
6689
- const tableRect = gripRowTableEl.getBoundingClientRect();
6690
- return x >= gr.left && x <= tableRect.left &&
6691
- 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);
6692
7104
  }
6693
7105
 
6694
7106
  function pointInColGripZone(x, y) {
@@ -6717,9 +7129,10 @@
6717
7129
  function updateTableEdgeGrips(x, y, target) {
6718
7130
  // Both grips are `position: fixed` overlays appended to document.body
6719
7131
  // (same as the hover-insert bubbles) rather than descendants of the
6720
- // table — both PAINT half outside it (the column grip straddling the top
6721
- // border, the row grip straddling the left border). So the moment the
6722
- // 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
6723
7136
  // the grip itself, `target` is the grip and is no
6724
7137
  // longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
6725
7138
  // Without this guard, that transition would hit the "nothing found"
@@ -6754,8 +7167,11 @@
6754
7167
  // Row grip: every row, including the header — the first row of a
6755
7168
  // markdown table IS the header, so any row must be draggable to the
6756
7169
  // top to become it. ONE position rule for all of them (user acceptance:
6757
- // 「grip 位置都一樣」) — centred on the table's left border, vertically
6758
- // 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
6759
7175
  // one exception is a header-only table (no body rows): its single row
6760
7176
  // is thead's only row, and dragging it away would empty the thead —
6761
7177
  // serializeTable() would degrade it and the user's table would vanish
@@ -6764,13 +7180,19 @@
6764
7180
  gripRowTableEl = tableEl;
6765
7181
  gripRowEl = rowEl;
6766
7182
  const r = rowEl.getBoundingClientRect();
6767
- // Fallback dims match .ed-te-grip-row's own CSS width/height exactly
6768
- // (20x28) — offsetWidth/Height read 0 while `hidden` (display: none)
6769
- // is still true on the FIRST show of a hover session, before the
6770
- // `hidden = false` assignment below takes effect.
6771
- 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.
6772
7194
  const gh = rowGrip.offsetHeight || 28;
6773
- rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
7195
+ rowGrip.style.left = tableRect.left + 'px';
6774
7196
  rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
6775
7197
  rowGrip.hidden = false;
6776
7198
  } else {
@@ -7124,6 +7546,820 @@
7124
7546
  currentBurst.history.snap('drag-col');
7125
7547
  }
7126
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
+
7127
8363
  document.addEventListener('pointerdown', (e) => {
7128
8364
  if (e.button !== 0) return;
7129
8365
  if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
@@ -7134,6 +8370,10 @@
7134
8370
  // capture) BEFORE starting a new one, so a stuck drag can never survive
7135
8371
  // into the next gesture and a fresh pointerdown always starts clean.
7136
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();
7137
8377
  // S3 Task 4: same defensive reasoning one line up, for the block-selection
7138
8378
  // press — and the flag that suppresses a gesture's own trailing click is
7139
8379
  // re-armed here so a gesture whose click never arrived cannot swallow the
@@ -7163,6 +8403,26 @@
7163
8403
  // still wins" true by construction: a second, competing pointerdown
7164
8404
  // listener would have to re-derive the hit test, and would race this
7165
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;
7166
8426
  if (e.shiftKey) { beginShiftClickSelection(e); return; }
7167
8427
  armBlockSelDrag(e);
7168
8428
  return;
@@ -7224,12 +8484,30 @@
7224
8484
  updateBlockSelDrag(e);
7225
8485
  });
7226
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
+
7227
8496
  document.addEventListener('pointerup', async (e) => {
7228
8497
  // S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
7229
8498
  // this file, so the block-selection drag ends on the pointer events, the
7230
8499
  // same skeleton the table drag uses. Before the `!tePointer` bail: a
7231
8500
  // selection drag is armed precisely when no grip gesture is in flight.
7232
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();
7233
8511
  if (!tePointer) return;
7234
8512
  const st = tePointer;
7235
8513
  releaseTeCapture(st);
@@ -7275,6 +8553,9 @@
7275
8553
  // actually moved mid-drag, only the indicator line).
7276
8554
  document.addEventListener('pointercancel', () => {
7277
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();
7278
8559
  // S3 Task 4: the same abort ends a block-selection drag. The selection it
7279
8560
  // has built so far STANDS (§4.4: nothing but Escape and a plain click
7280
8561
  // clears) — what must not survive is the live drag, or the next stray
@@ -7291,6 +8572,9 @@
7291
8572
  // when no gesture is in flight, so this is safe to fire on every blur.
7292
8573
  window.addEventListener('blur', () => {
7293
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();
7294
8578
  // S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
7295
8579
  // drag in flight when focus left is exactly as undeliverable-pointerup as
7296
8580
  // a row drag's, so it is torn down here too.
@@ -7937,6 +9221,11 @@
7937
9221
  // closeGutterMenu() first, so gutterMenuBlockEl is null by then.
7938
9222
  if (e.key === 'Escape') {
7939
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; }
7940
9229
  if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
7941
9230
  if (gutterMenuBlockEl || insertMenuBlockEl) {
7942
9231
  e.preventDefault();
@@ -8312,6 +9601,20 @@
8312
9601
  hideTableGrips();
8313
9602
  return;
8314
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
+ }
8315
9618
  updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
8316
9619
  updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
8317
9620
  });