@helping-ai-workflow/md2doc 2.12.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);
@@ -1692,6 +2009,30 @@
1692
2009
  '<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
1693
2010
  // MD 原始碼 — angle brackets: source, not prose.
1694
2011
  code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
2012
+ // ── v3.0.1: one icon per 轉換成 target ────────────────────────────────
2013
+ // Keys are CONVERT_TARGETS[].id verbatim; openConvertSubmenu() looks them
2014
+ // up by that id, so a rename on either side must be made on both.
2015
+ // 文字 — a paragraph pilcrow, drawn rather than typed so it inherits the
2016
+ // same stroke weight as every sibling icon.
2017
+ text: '<path d="M9 2.5H6.2a2.7 2.7 0 0 0 0 5.4H9"/><path d="M9 2.5v11"/><path d="M11.8 2.5v11"/>',
2018
+ // 標題 1–6 — an "H" plus the level, as two strokes and a numeral bar set.
2019
+ h1: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11.5 6.5l1.5-1v7.5"/>',
2020
+ h2: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 6.6a1.6 1.6 0 0 1 2.7 1.1c0 1.4-2.7 2.6-2.7 5.3h3"/>',
2021
+ h3: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 5.8h2.7l-1.6 2.6a1.8 1.8 0 1 1-1.3 3"/>',
2022
+ h4: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13 5.8v7.2"/><path d="M13 10.7h-2.6l2.2-4.9"/>',
2023
+ h5: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.5 5.8h-2.6v2.8a1.9 1.9 0 1 1-.5 3.6"/>',
2024
+ h6: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.4 6.2a1.9 1.9 0 0 0-3 1.5v3.2a1.8 1.8 0 1 0 3.1-1.3 1.8 1.8 0 0 0-3.1 1.3"/>',
2025
+ // 項目符號列表 — three dots with three rules.
2026
+ ul: '<circle cx="3.2" cy="4.2" r="1"/><circle cx="3.2" cy="8" r="1"/><circle cx="3.2" cy="11.8" r="1"/>' +
2027
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8h7.4"/><path d="M6.4 11.8h7.4"/>',
2028
+ // 編號列表 — the same three rules led by 1/2/3 strokes.
2029
+ ol: '<path d="M2.2 3.4l1-.6v2.8"/><path d="M2 8.2a1 1 0 0 1 1.7.7c0 .9-1.7 1.6-1.7 2.4h1.9"/>' +
2030
+ '<path d="M2.1 11.4h1.6l-1 1.1a1 1 0 1 1-.7 1.2"/>' +
2031
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8.6h7.4"/><path d="M6.4 12.9h7.4"/>',
2032
+ // 待辦清單 — a checked box.
2033
+ task: '<rect x="2.2" y="2.6" width="10.8" height="10.8" rx="2.4"/><path d="M5.2 8.2l2.1 2.1 3.8-4"/>',
2034
+ // 引用 — a quote bar plus two shortened rules.
2035
+ quote: '<path d="M3 3.4v9.2"/><path d="M6.4 5.4h7.2"/><path d="M6.4 8h7.2"/><path d="M6.4 10.6h4.6"/>',
1695
2036
  };
1696
2037
  function menuIconMarkup(name) {
1697
2038
  return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
@@ -1874,7 +2215,13 @@
1874
2215
  const b = document.createElement('button');
1875
2216
  b.type = 'button';
1876
2217
  b.className = 'ed-handle-menu-btn';
1877
- b.textContent = t.label;
2218
+ // v3.0.1: the submenu carries icons too. Without them its labels start
2219
+ // at the button's own padding while the parent menu's start past a 16px
2220
+ // icon, so the two levels' text does not share a vertical line — the
2221
+ // exact defect the user reported. `.ed-handle-menu-btn` is already a
2222
+ // flex row with a gap, so an icon plus a text node is all it takes.
2223
+ b.innerHTML = menuIconMarkup(t.id);
2224
+ b.appendChild(document.createTextNode(t.label));
1878
2225
  b.setAttribute('aria-label', 'Convert to ' + t.id);
1879
2226
  b.addEventListener('click', (e) => {
1880
2227
  e.stopPropagation();
@@ -2558,6 +2905,48 @@
2558
2905
  const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
2559
2906
  const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
2560
2907
  const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
2908
+ // S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
2909
+ //
2910
+ // Task 3 raised all of these with a single provisional constant
2911
+ // (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
2912
+ // owned the final wording. They are three different problems with three
2913
+ // OPPOSITE remedies, so one sentence for all of them sends two thirds of
2914
+ // the users to fix the wrong end of their own gesture:
2915
+ // * the SOURCE seam — the block cannot leave where it is, because the two
2916
+ // lists it stands between would join into one loose run. No destination
2917
+ // helps; the remedy is not to move it (or to move something else).
2918
+ // * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
2919
+ // is to aim somewhere else, which is the exact opposite advice.
2920
+ // * OUT OF RUN — a list item may only move among its own §3.8 siblings.
2921
+ // The remedy is to stay inside its own list.
2922
+ // Naming follows the BATCH_*_MESSAGE convention above. None of the three is
2923
+ // a substring of another, of §4.3's own gate message, or of
2924
+ // BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
2925
+ // refusal fired rather than that some banner mentioning 清單 appeared.
2926
+ const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
2927
+ const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
2928
+ const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
2929
+ // S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
2930
+ // of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
2931
+ // removal half of the move through clampIndents()'s `{ removed: true }`),
2932
+ // which answers every SOURCE-side orphan. What no removal-clamp can reach is
2933
+ // the INSERTION half: a BATCH whose last member is SHALLOWER than the block
2934
+ // it lands before leaves that block with no parent chain above it, at the
2935
+ // NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
2936
+ // indent array of length 2..6 and depth 0..3, every operand-set size and
2937
+ // every destination the run gate admits: 4067 drops, 1425 refused by Task
2938
+ // 4's predicate, 14 still unanchored after the clamp — every one of them a
2939
+ // multi-block set breaking at the block immediately after the landed set.
2940
+ // (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
2941
+ //
2942
+ // RE-WORDED with the narrowing, and the wording is the point: Task 4's
2943
+ // 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
2944
+ // was true while the capability was missing and is a lie now that it is not.
2945
+ // What is left is a DESTINATION objection with the same remedy
2946
+ // BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
2947
+ // end of the gesture is at fault. Still not a substring of any other refusal
2948
+ // in this file, which is what lets a test assert WHICH one fired.
2949
+ const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
2561
2950
  // 轉換成's own refusals are NOT declared here. They are per block TYPE, and
2562
2951
  // they are the same ruling §3.7 applies to a single block's ⠿ — so they live
2563
2952
  // with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
@@ -3641,6 +4030,10 @@
3641
4030
  hideTableGrips();
3642
4031
  hideTableEdgeMenu();
3643
4032
  cancelTeDrag();
4033
+ // S4 Task 2: and an in-flight ⠿ block drag, for the same reason — this
4034
+ // resolution is about to commit and re-render whichever block it holds an
4035
+ // ordinal into.
4036
+ cancelBlockDrag();
3644
4037
  if (!burst) return true;
3645
4038
  // §10-gap fix (review): this burst's block's "pristine" window (see
3646
4039
  // `pristineInsert`'s own comment) closes right here, the moment its
@@ -3823,7 +4216,16 @@
3823
4216
  // S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
3824
4217
  // emitted type:'list' blocks since Phase 4, so no startBurst() call could
3825
4218
  // produce blockType === 'list' and the branch was already dead code.
3826
- const result = burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
4219
+ // v3.0.1: hand the serializer the table's CURRENT source lines so it can
4220
+ // reuse the author's own column widths instead of flattening the whole
4221
+ // table to minimal form over a one-cell edit. extractBlockSource() is the
4222
+ // same helper the raw editor prefills from, so "what the file says" has
4223
+ // exactly one definition.
4224
+ const tableBlockRec = burst.blockType === 'table'
4225
+ ? blocks.find((b) => b.id === burst.blockId) : null;
4226
+ const result = burst.blockType === 'table'
4227
+ ? tableMd.serializeTable(burst.editEl,
4228
+ tableBlockRec ? extractBlockSource(lines, tableBlockRec) : null)
3827
4229
  : inlineMd.serializeInline(burst.editEl);
3828
4230
  if (result.unsupported.length > 0) {
3829
4231
  // Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
@@ -5105,16 +5507,29 @@
5105
5507
  function indentListItem(blockEl) {
5106
5508
  const self = liAttrs(blockEl);
5107
5509
  if (!self) return false;
5108
- // Rule (d): the first item of a LIST has nothing above it to nest under.
5109
- // Without this the §3.4 clamp would happily read the previous list's last
5110
- // item as "the previous block" and indent this one underneath it, merging
5111
- // two lists the user never asked to join. Pre-S1 this fell out of
5112
- // `previousElementSibling` being null inside the item's own <ul>.
5113
- if (self.listStart) return false;
5114
5510
  const all = allBlockEls();
5115
5511
  const i = all.indexOf(blockEl);
5116
5512
  if (i < 0) return false;
5117
5513
  const prev = liAttrs(all[i - 1]);
5514
+ // Rule (d), narrowed (v3.0.1). The old rule was `if (self.listStart)
5515
+ // return false` — the first item of a LIST has nothing above it to nest
5516
+ // under. That over-refused: a bullet outdented to indent 0 directly
5517
+ // BELOW an ordered item is a listStart (a ul following an ol) whose
5518
+ // previous block is a perfectly good parent, and the user could never
5519
+ // indent it back. The half that still has to be refused is the one the
5520
+ // old rule was really aimed at: two list tokens the user kept apart with
5521
+ // a BLANK LINE. Indenting the second one makes listRunOf() span both,
5522
+ // and re-serializing that span swallows the blank line — a rewrite of
5523
+ // lines nobody touched, which is the one thing this editor must never do.
5524
+ // Contiguity is a fact about the FILE, so it is read off `blocks`, not
5525
+ // guessed from the DOM.
5526
+ if (self.listStart) {
5527
+ if (!prev) return false;
5528
+ const prevRec = blockRecOf(all[i - 1]);
5529
+ const selfRec = blockRecOf(blockEl);
5530
+ if (!prevRec || !selfRec) return false;
5531
+ if (prevRec.endLine + 1 !== selfRec.startLine) return false;
5532
+ }
5118
5533
  const max = prev ? prev.indent + 1 : 0;
5119
5534
  const next = Math.min(self.indent + 1, max);
5120
5535
  if (next === self.indent) return false;
@@ -5191,22 +5606,39 @@
5191
5606
 
5192
5607
  // How far ONE member may move, as a magnitude (0 or 1), never a signed
5193
5608
  // delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
5194
- // indentListItem()'s own single-item rule — the list-start clause included,
5195
- // so a set that opens a list cannot nest itself under the previous list's
5196
- // last item — and the floor is outdentListItem()'s (indent 0 cannot rise).
5197
- // Both read PRE-move indents, which is §3.4's global convention ("the
5198
- // operated block's indent" always means the value before the operation), and
5199
- // it is what makes the minimum below well-defined: every member is measured
5200
- // against the document as it stands, not against members already moved.
5609
+ // indentListItem()'s own single-item rule, v3.0.1's narrowed list-start
5610
+ // clause included: a listStart member still refuses UNLESS the block
5611
+ // immediately before it (by file line, via blockRecOf — same contiguity
5612
+ // check, same code shape as indentListItem()'s, kept in that shape
5613
+ // deliberately so the two stay in visible agreement) is a contiguous list
5614
+ // item, in which case it has real head-room like any other item. Before
5615
+ // v3.0.1 this was unconditional (`if (self.listStart) return 0;`) —
5616
+ // batch Tab on a listStart block was refused outright, matching
5617
+ // indentListItem()'s then-unconditional refusal. Narrowing ONE of the two
5618
+ // without the other silently reintroduces the single-item defect on the
5619
+ // batch axis: a listStart item directly below a contiguous ordered item,
5620
+ // selected as part of a batch, would stay refused even though the very
5621
+ // same block indents fine on its own. The floor is outdentListItem()'s
5622
+ // (indent 0 cannot rise), untouched by this narrowing. Both read PRE-move
5623
+ // indents, which is §3.4's global convention ("the operated block's
5624
+ // indent" always means the value before the operation), and it is what
5625
+ // makes the minimum below well-defined: every member is measured against
5626
+ // the document as it stands, not against members already moved.
5201
5627
  function memberIndentHeadroom(el, dir) {
5202
5628
  const self = liAttrs(el);
5203
5629
  if (!self) return 0;
5204
5630
  if (dir < 0) return self.indent > 0 ? 1 : 0;
5205
- if (self.listStart) return 0;
5206
5631
  const all = allBlockEls();
5207
5632
  const i = all.indexOf(el);
5208
5633
  if (i < 0) return 0;
5209
5634
  const prev = liAttrs(all[i - 1]);
5635
+ if (self.listStart) {
5636
+ if (!prev) return 0;
5637
+ const prevRec = blockRecOf(all[i - 1]);
5638
+ const selfRec = blockRecOf(el);
5639
+ if (!prevRec || !selfRec) return 0;
5640
+ if (prevRec.endLine + 1 !== selfRec.startLine) return 0;
5641
+ }
5210
5642
  const max = prev ? prev.indent + 1 : 0;
5211
5643
  return Math.max(0, Math.min(1, max - self.indent));
5212
5644
  }
@@ -5260,7 +5692,30 @@
5260
5692
  // segment delta for the blocks below instead of N independent clamps, and
5261
5693
  // its rule 1 then walks the operated blocks in document order so each is
5262
5694
  // measured against the member above it that the same pass just settled.
5263
- applyIndentClamp(run, liEls, oldIndent);
5695
+ //
5696
+ // v3.0.1 (review round 1 follow-up): `run` above is batchRunOf()'s
5697
+ // PRE-mutation snapshot — the exact same staleness handleLiKeydown()'s
5698
+ // single-item Tab branch had before its own v3.0.1 fix (see that
5699
+ // call's comment). For a single-member batch on a listStart item whose
5700
+ // ceiling memberIndentHeadroom() has just narrowed (a bullet directly
5701
+ // below a contiguous ordered item), the pre-mutation run is `[liEls[0]]`
5702
+ // alone — batchRunOf() never walked backward past the list-start
5703
+ // boundary, because before this fix nothing could ever cross it. With
5704
+ // no anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
5705
+ // bound the indent the forEach above just wrote against, and clamps it
5706
+ // straight back to 0 — silently undoing the write, MEASURED directly:
5707
+ // narrowing memberIndentHeadroom() alone left the covering test RED.
5708
+ // A FRESH listRunOf(liEls[0]) call here — taken after the write above —
5709
+ // walks back from the NEW indent and finds the real parent, exactly
5710
+ // like the post-mutation `liveRun = listRunOf(liEls[0])` a few lines
5711
+ // below (the COMMIT span) already does; using the same fresh call for
5712
+ // the clamp keeps the two consistent, same reasoning as
5713
+ // handleLiKeydown()'s Tab branch. Provably identical to the old stale
5714
+ // `run` for every case where no member was ever a list-start
5715
+ // (listRunOf() slices by INDEX RANGE, and neither the range's
5716
+ // start/end index nor the elements between them shift when only
5717
+ // already-run-member indents change).
5718
+ applyIndentClamp(listRunOf(liEls[0]), liEls, oldIndent);
5264
5719
  });
5265
5720
  // Re-derived AFTER the mutation, like the caret Tab's: an indent change can
5266
5721
  // move a block between runs.
@@ -5491,7 +5946,26 @@
5491
5946
  const oldIndent = Number(li.getAttribute('data-indent')) || 0;
5492
5947
  const changed = mutateListRun(() => {
5493
5948
  if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
5494
- applyIndentClamp(run, li, oldIndent);
5949
+ // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
5950
+ // still had its OLD indent. That is fine for outdentListItem() — the
5951
+ // clamp's model rereads every element's CURRENT data-indent off the
5952
+ // DOM, so it self-corrects regardless of which elements the snapshot
5953
+ // names — but indentListItem() can now move a rule-(d) list-start
5954
+ // item (a bullet directly below an ordered item, say) whose own
5955
+ // pre-mutation run is `[li]` alone: nothing shallower ever preceded
5956
+ // it, because runBlocksOf() stops at a list-start by design. With no
5957
+ // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
5958
+ // bound the indent this call just wrote against, and clamps it
5959
+ // straight back to 0 — silently undoing the write. A FRESH
5960
+ // listRunOf(li) call here — taken after the write above — walks back
5961
+ // from li's NEW indent and finds the real parent, exactly like the
5962
+ // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
5963
+ // span) already does; using the same fresh call for the clamp keeps
5964
+ // the two consistent. Provably identical to the old stale `run` for
5965
+ // every case where li was never a list-start (listRunOf() slices by
5966
+ // INDEX RANGE, and neither the range's start/end index nor the
5967
+ // elements between them shift when only li's own indent changes).
5968
+ applyIndentClamp(listRunOf(li), li, oldIndent);
5495
5969
  return true;
5496
5970
  });
5497
5971
  if (!changed) return true;
@@ -6063,8 +6537,12 @@
6063
6537
  function tableIdentityOf(tableEl) {
6064
6538
  const headerRow = tableEl ? headerRowOf(tableEl) : null;
6065
6539
  if (!headerRow) return null;
6066
- return allRowsOf(tableEl).length + '' +
6067
- Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('');
6540
+ // The two delimiters are written as ESCAPES, not as literal control
6541
+ // bytes. They used to be literal, which made grep classify this whole
6542
+ // 9643-line file as binary and SILENTLY TRUNCATE its output — a real
6543
+ // investigation was misled by it. The runtime value is identical.
6544
+ return allRowsOf(tableEl).length + '\x01' +
6545
+ Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('\x00');
6068
6546
  }
6069
6547
  async function ensureTableBurstOpen(tableEl) {
6070
6548
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
@@ -6229,8 +6707,9 @@
6229
6707
  // grips below are the fix: real, adequately-sized (≥18×24px) elements the
6230
6708
  // user can actually see and aim for. Both grips are overlay elements
6231
6709
  // `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
6710
+ // contenteditable cell, even though the row grip visually overlaps one
6711
+ // (since §4.2 conflict 2 landed in S4 T1 it sits ENTIRELY inside the first
6712
+ // column, within that cell's own widened left padding; see the geometry
6234
6713
  // note in the "Notion-style row/column grip handles" section below). So —
6235
6714
  // unlike the old zones, which
6236
6715
  // sat INSIDE an already-permanently-contenteditable cell and needed the
@@ -6543,20 +7022,59 @@
6543
7022
  // listener (wired near the bottom of this file) that already drives
6544
7023
  // updateTableInsertBubbles() — see its own comment for the coalescing
6545
7024
  // 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.
7025
+ // Geometry, per axis (v3.0.1: they are mirror images again):
7026
+ // COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
7027
+ // its hit rect straddles that border by half its own height on each
7028
+ // side (P0-a).
7029
+ // ROW grip: its CENTRELINE coincides with the table's LEFT border, so
7030
+ // its hit rect straddles that border by half its own width on each
7031
+ // side — same shape as the column grip, restored in v3.0.1 (spec §4.2
7032
+ // hit-test conflict 2).
7033
+ //
7034
+ // History: why the row axis briefly broke the symmetry, and how that was
7035
+ // undone. The block's own gutter ⠿ hangs outside the content box, and a
7036
+ // table block's own table starts at that same left edge, so a row grip
7037
+ // centred on the table's left border occupied [blockLeft-10, blockLeft+10]
7038
+ // and overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
7039
+ // positioned overlays and the grip is a document.body child at z-index 7,
7040
+ // so the grip won that hit test whenever it was showing — which is
7041
+ // whenever the pointer had recently been over a table cell. MEASURED on
7042
+ // v2.12.0 at 1400x900 with the row grip parked on the HEADER row (the only
7043
+ // row whose 28px-tall grip reaches the ⠿'s own top-20px band):
7044
+ // elementFromPoint() walked across the table block's ⠿ at its vertical
7045
+ // centre answered .ed-handle for x 390..401 and the ROW GRIP for x
7046
+ // 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
7047
+ // a table ROW drag instead of a block move.
7048
+ //
7049
+ // S4 T1 (v2.12.0) fixed that by INSETTING the grip instead — its left edge
7050
+ // on the table's left border, entirely inside the table, no more straddle.
7051
+ // That broke the row/column symmetry (the column grip still straddles the
7052
+ // table's top border, because nothing of the page's own chrome lives up
7053
+ // there) and was reported as a v3.0.1 visual regression. An even earlier
7054
+ // revision (v2.10.1) had already tried an inset like it and been reverted,
7055
+ // because a 20px grip inside a cell whose left padding is 14px sits on
7056
+ // that cell's TEXT — S4 T1 avoided repeating that by widening the first
7057
+ // column's own edit-mode padding to clear the inset grip.
7058
+ //
7059
+ // v3.0.1 undoes the inset rather than re-tuning it: the row grip goes back
7060
+ // to straddling the border (this file's change, see updateTableEdgeGrips()
7061
+ // below), and the ⠿/+ pair itself moves further LEFT instead (a shift
7062
+ // token declared alongside the gutter's own tokens in lib/md2doc.js) — the
7063
+ // overlap is removed at its SOURCE, not by moving the grip out of the
7064
+ // ⠿'s way. That also means the first column's edit-mode padding widening
7065
+ // is gone: the grip's straddling half (10px) fits inside the column's
7066
+ // default 14px padding on its own, so v2.10.1's original defect does not
7067
+ // come back. Neither the ⠿'s right edge nor the grip's left edge is
7068
+ // written down as a literal on either side of the gap between them — see
7069
+ // the shift-token comment in lib/md2doc.js's edit-mode :root block, which
7070
+ // this file deliberately does not name (test/editor-client.test.js forbids
7071
+ // the retired hover-gutter class name as a bare substring and the token
7072
+ // name contains it) — so the two cannot drift apart the way v2.11.0's pair
7073
+ // position once did (the CSS comment claiming "an 8px gap" was describing
7074
+ // that stale position, which v2.11.1 replaced).
7075
+ // test/editor-client-runtime.test.js asserts BOTH directions: every pixel
7076
+ // of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
7077
+ // grip's own centre answers the row grip.
6560
7078
  // Either way the grip's hit rect DOES overlap the insert bubble's hit
6561
7079
  // rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
6562
7080
  // own axis). Non-intersection via rect separation is no longer possible
@@ -6572,6 +7090,19 @@
6572
7090
  // reported overlap corner AND verifies the z-index ordering directly —
6573
7091
  // re-verify it if either the grip's or bubble's z-index ever changes.
6574
7092
 
7093
+ // --ed-te-grip-row-w, read once and cached. The token is the single source
7094
+ // of truth for the grip's width (lib/md2doc.js declares it); parsing it here
7095
+ // means the JS never carries a second copy of the number.
7096
+ let gripRowWidthCache = null;
7097
+ function gripRowWidthPx() {
7098
+ if (gripRowWidthCache !== null) return gripRowWidthCache;
7099
+ const raw = getComputedStyle(document.documentElement)
7100
+ .getPropertyValue('--ed-te-grip-row-w');
7101
+ const n = parseFloat(raw);
7102
+ gripRowWidthCache = Number.isFinite(n) && n > 0 ? n : 20;
7103
+ return gripRowWidthCache;
7104
+ }
7105
+
6575
7106
  function buildTableGrip(cls, ariaLabel) {
6576
7107
  const b = document.createElement('button');
6577
7108
  b.type = 'button';
@@ -6635,12 +7166,18 @@
6635
7166
  // gripCenter(), which jump straight to the grip's own coordinates) could
6636
7167
  // ever land on it; a real mouse gesture could not.
6637
7168
  //
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.
7169
+ // Current geometry (v3.0.1 restored the row axis to mirror the column
7170
+ // axis — see the "Notion-style row/column grip handles" section comment
7171
+ // above for the S4 T1 detour into an inset row grip and how it was
7172
+ // undone): BOTH grips straddle their own border again, the COLUMN grip
7173
+ // half above / half below the table's TOP border and the ROW grip half
7174
+ // outside / half inside the table's LEFT border. The corridor-crossing
7175
+ // bug described above therefore applies to BOTH axes' straddling shape in
7176
+ // principle, and pointInColGripZone() still covers the column's
7177
+ // outside-the-border corridor exactly as before. pointInRowGripZone()
7178
+ // deliberately does NOT get an equivalent corridor formula back, even
7179
+ // though the straddle is back — see its own comment for why the padded
7180
+ // grip rect alone is still sufficient.
6644
7181
  //
6645
7182
  // Review fix (Important, first pass over-permissive): the first version of
6646
7183
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -6653,14 +7190,28 @@
6653
7190
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
6654
7191
  // pointInColGripZone() below) instead of the whole table.
6655
7192
  //
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.
7193
+ // What each keep-zone covers TODAY:
7194
+ // pointInColGripZone() keeps the ORIGINAL corridor shape: the union of
7195
+ // (the column grip's own padded rect) and (the straight strip between
7196
+ // the grip's own TOP edge and the table's top border, x clamped to the
7197
+ // anchor COLUMN's own horizontal extent, padded). The column grip
7198
+ // straddles the top border, so that corridor is real.
7199
+ // pointInRowGripZone() is the padded grip rect ALONE, no corridor term —
7200
+ // kept that way through the v3.0.1 straddle restoration on purpose,
7201
+ // not left behind by accident. A pointer travelling from inside a body
7202
+ // cell to the row grip's own centre (which sits exactly on the
7203
+ // table's left border) never has to leave the table's horizontal
7204
+ // extent on the way there, so the padded rect alone already covers
7205
+ // every REAL (non-teleporting) approach this file's own test suite
7206
+ // exercises — see "table grips: the row grip straddles the table's
7207
+ // left border again…" in test/editor-client-runtime.test.js. That is
7208
+ // narrower than the column axis's guarantee (nothing approaches the
7209
+ // row grip from further out along the table's own left edge the way a
7210
+ // pointer can approach the column grip from above it), which is why
7211
+ // the two functions are allowed to stay asymmetric even though the
7212
+ // grips' shapes are not. If a future report finds a real pointer path
7213
+ // that does need a corridor term here, that is a new, separate defect
7214
+ // — not evidence this reasoning was wrong when it was written.
6664
7215
  // A pointer outside either grip's own zone is a genuine exit and still
6665
7216
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
6666
7217
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -6680,15 +7231,33 @@
6680
7231
  function pointInRowGripZone(x, y) {
6681
7232
  if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
6682
7233
  !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;
7234
+ // v3.0.1 恢復跨界:row grip 的中心線重新落在表格左邊界上(不再是
7235
+ // S4 T1 那個左緣頂在邊界上、整個縮進表格內側的做法)。與 ⠿ 的重疊
7236
+ // 改在源頭解決——往左再位移的是頁面 gutter 的 ⠿/+ 那一對按鈕本身
7237
+ // (多移動一個位移 token,宣告在 lib/md2doc.js 的編輯模式 :root 區
7238
+ // 塊,此檔案依慣例不直接引用其名稱),不是移動 grip。
7239
+ //
7240
+ // 走廊公式沒有跟著補回來,是刻意維持的範圍決定,不是漏掉:
7241
+ // TE_GRIP_ZONE_PAD_PX=4 的 padded rect 涵蓋 [tableLeft-14,
7242
+ // tableLeft+14](grip 本身 20px 寬、跨在邊界上是 [tableLeft-10,
7243
+ // tableLeft+10],外加 4px 緩衝)。指標從儲存格內側走向 grip 中心
7244
+ // (正好落在 tableLeft)的整段路徑,x 一律 >= tableLeft,從未離開
7245
+ // 表格的水平範圍,所以「儲存格 -> grip」這條測試套件唯一驗證過的
7246
+ // 真實(非瞬移)路徑,本來就不需要走廊公式。哪天真的量到一條需要
7247
+ // 走廊的真實路徑(例如從表格左側、gutter 那一側靠近),那是新的、
7248
+ // 獨立的缺陷,不代表這裡當初的推論錯了。
7249
+ //
7250
+ // 誠實記一筆巧合,不要誤讀成保證:⠿ 的右緣恰好落在 tableLeft-14,
7251
+ // 與這個 padded rect 的左緣相觸、不重疊——這是 TE_GRIP_ZONE_PAD_PX
7252
+ // 這個寫死的 4,與 lib/md2doc.js 那個 4px 呼吸間距 token 兩個各自
7253
+ // 獨立宣告出來的巧合,不是誰推導誰的保證。任一邊數值改動,觸點都
7254
+ // 可能變成 1px 縫隙或重疊,沒有任何型別檢查會抓到。這是既有狀態,
7255
+ // 不是本次修正引入或修掉的東西。
7256
+ //
7257
+ // 對使用者可見的差別:離開表格左側不再等於立刻收掉 row grip——padded
7258
+ // rect 本身就已經探出表格邊界 14px,所以指標要離開這整個 padded rect
7259
+ // (水平或垂直)才會真的收掉,錨定那一列的高度也不例外。
7260
+ return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
6692
7261
  }
6693
7262
 
6694
7263
  function pointInColGripZone(x, y) {
@@ -6717,9 +7286,10 @@
6717
7286
  function updateTableEdgeGrips(x, y, target) {
6718
7287
  // Both grips are `position: fixed` overlays appended to document.body
6719
7288
  // (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
7289
+ // table — the column grip PAINTS half outside it (straddling the top
7290
+ // border) and the row grip paints inside the first column (§4.2
7291
+ // conflict 2). Either way neither is a table descendant, so the moment
7292
+ // the real pointer crosses from a cell onto
6723
7293
  // the grip itself, `target` is the grip and is no
6724
7294
  // longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
6725
7295
  // Without this guard, that transition would hit the "nothing found"
@@ -6754,8 +7324,12 @@
6754
7324
  // Row grip: every row, including the header — the first row of a
6755
7325
  // markdown table IS the header, so any row must be draggable to the
6756
7326
  // 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
7327
+ // 「grip 位置都一樣」) — its CENTRELINE on the table's left border, i.e.
7328
+ // straddling it half outside / half inside the table, restored in
7329
+ // v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
7330
+ // buildTableGrip() for the S4 T1 detour into an inset grip and how it
7331
+ // was undone), vertically centred on its own row; no
7332
+ // header special case. The
6759
7333
  // one exception is a header-only table (no body rows): its single row
6760
7334
  // is thead's only row, and dragging it away would empty the thead —
6761
7335
  // serializeTable() would degrade it and the user's table would vanish
@@ -6764,12 +7338,20 @@
6764
7338
  gripRowTableEl = tableEl;
6765
7339
  gripRowEl = rowEl;
6766
7340
  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
7341
+ // Fallback height matches .ed-te-grip-row's own CSS height exactly
7342
+ // (28) — offsetHeight reads 0 while `hidden` (display: none) is still
7343
+ // true on the FIRST show of a hover session, before the
6770
7344
  // `hidden = false` assignment below takes effect.
6771
- const gw = rowGrip.offsetWidth || 20;
6772
7345
  const gh = rowGrip.offsetHeight || 28;
7346
+ // v3.0.1: back to STRADDLING the table's left border (the S4 T1 inset
7347
+ // was a workaround for the ⠿ hit-test overlap; the ⠿/+ pair has
7348
+ // since moved further left via a CSS shift token in lib/md2doc.js,
7349
+ // which removes the overlap at its source). The width is read from the
7350
+ // token rather than written as a literal here: offsetWidth reads 0
7351
+ // while `hidden` is still true on the first show of a hover session,
7352
+ // and a hard-coded fallback is exactly the CSS/JS drift the token
7353
+ // exists to prevent.
7354
+ const gw = rowGrip.offsetWidth || gripRowWidthPx();
6773
7355
  rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
6774
7356
  rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
6775
7357
  rowGrip.hidden = false;
@@ -7124,6 +7706,820 @@
7124
7706
  currentBurst.history.snap('drag-col');
7125
7707
  }
7126
7708
 
7709
+ // ── S4 Task 2: dragging a BLOCK by its ⠿ handle (spec §4.5) ─────────────
7710
+ // The gesture SKELETON only — threshold, capture, drop-target computation,
7711
+ // the indicator, and every cancellation path. THE DROP IS A NO-OP; Tasks
7712
+ // 3-7 add the commits. §4.5 says to reuse the table row drag's skeleton
7713
+ // above, and this does, including its central invariant:
7714
+ //
7715
+ // NOTHING IN THE DOM IS MUTATED DURING A DRAG. Only the indicator moves.
7716
+ //
7717
+ // That is the whole reason cancelBlockDrag() can be called unconditionally
7718
+ // from Escape / pointercancel / window blur / the next pointerdown /
7719
+ // rerenderAll() / resolveBurst() with nothing to revert — see
7720
+ // cancelTeDrag()'s own comment for the same argument on the row axis. It
7721
+ // also means no `class=""` residue can reach a burst's zero-edit guard (the
7722
+ // row path had to strip one by hand, because it DIMS the dragged <tr>; this
7723
+ // gesture deliberately dims nothing, so the question never arises). Even the
7724
+ // ⠿ menu, which lives as a CHILD of its block and would therefore be a
7725
+ // content mutation to close, is left alone until the gesture is over.
7726
+ // S4 Task 2b: the drag-cursor class, on `document.documentElement`. Named
7727
+ // once so the add, the remove and lib/md2doc.js's rule cannot drift apart.
7728
+ const BLOCK_DRAGGING_CLASS = 'ed-block-dragging';
7729
+ const blockDropIndicator = document.createElement('div');
7730
+ blockDropIndicator.className = 'ed-block-drop-indicator';
7731
+ blockDropIndicator.hidden = true;
7732
+ document.body.appendChild(blockDropIndicator);
7733
+
7734
+ // The drop-target candidates: the document's block sequence, in document
7735
+ // order. MEASURED on this branch (1400x900, a doc mixing paragraphs, a
7736
+ // heading, a list run with a nested item, a same-line-nest PHANTOM, a
7737
+ // fenced code block and a table): every single `.ed-block` is a DIRECT
7738
+ // child of `contentEl` — S1 flattened the <ul>/<ol> containers away — so
7739
+ // allBlockEls() already IS that sequence, with no nesting to flatten and no
7740
+ // second, overlapping y-range sitting inside another candidate's box.
7741
+ // Named rather than inlined because Tasks 3-7 re-resolve against it.
7742
+ function blockDropCandidates() {
7743
+ return allBlockEls();
7744
+ }
7745
+
7746
+ // Nearest block-drop target for `clientY` — a discriminated union:
7747
+ // {mode: 'before-block', blockIndex, y} | {mode: 'append', y}
7748
+ // `blockIndex` is an ORDINAL into blockDropCandidates(); never a
7749
+ // `data-block-id` (blockmap.js renumbers 0..n-1 on every render, so a
7750
+ // captured id names the target's NEIGHBOUR after any commit that changes
7751
+ // the block count) and never a node (a mid-gesture commit detaches them).
7752
+ //
7753
+ // spec §4.6 replaced the table's `{beforeRow, y}` with exactly this shape,
7754
+ // because a null `beforeRow` had been overloaded to mean "append". That
7755
+ // ruling is followed here rather than nearestColDropTarget()'s flat
7756
+ // `{index, x}`, which is the one place it was never applied.
7757
+ //
7758
+ // There is no 'above-header' counterpart: a table's first row IS its
7759
+ // header, so promoting a row to first place needed its own mode; the
7760
+ // document has no such distinguished first block, and `before-block 0`
7761
+ // already says "become the new first block".
7762
+ function nearestBlockDropTarget(clientY) {
7763
+ const els = blockDropCandidates();
7764
+ for (let i = 0; i < els.length; i++) {
7765
+ const r = els[i].getBoundingClientRect();
7766
+ if (clientY < r.top + r.height / 2) {
7767
+ return { mode: 'before-block', blockIndex: i, y: r.top };
7768
+ }
7769
+ }
7770
+ const last = els[els.length - 1];
7771
+ const rect = last ? last.getBoundingClientRect() : contentEl.getBoundingClientRect();
7772
+ return { mode: 'append', y: rect.bottom };
7773
+ }
7774
+
7775
+ // The in-flight ⠿ gesture, or null between gestures — the block-axis
7776
+ // counterpart of `tePointer`. `sourceIndex` is the ORDINAL of the pressed
7777
+ // block AT PRESS TIME, which is the table drop's own rule ("convert the
7778
+ // pressed node to an ordinal first, re-resolve in the live DOM at drop
7779
+ // time"): between the press and the release, resolveGutterOperands()'s
7780
+ // switchAwayFrom() may commit some other dirty burst, re-render, and detach
7781
+ // every node in the document. `dragging` flips true once
7782
+ // TE_DRAG_THRESHOLD_PX is crossed; `dropTarget` is the union above, filled
7783
+ // in by updateBlockDropIndicator() as the pointer moves.
7784
+ let blockDragState = null;
7785
+
7786
+ // Arms the gesture for a press on a ⠿. Returns TRUE when it owns this
7787
+ // pointer, so the `pointerdown` listener below can stop before it reaches
7788
+ // beginShiftClickSelection()/armBlockSelDrag().
7789
+ //
7790
+ // Running FIRST is required and is also free: `.ed-handle` is a member of
7791
+ // ED_SEL_GESTURE_CHROME, so armBlockSelDrag()'s very first line already
7792
+ // returned for exactly this target (and beginShiftClickSelection()'s does
7793
+ // too) — which is why a press on the ⠿ recorded NO pointer state at all
7794
+ // before this task. Nothing about a press anywhere else changes.
7795
+ //
7796
+ // No preventDefault() here, deliberately. wireBlockSelection()'s delegated
7797
+ // `mousedown` preventDefault() on `.ed-handle` is what keeps a dirty
7798
+ // burst's focus intact across a ⠿ press — without it the press blurs the
7799
+ // burst, the blur commits, the commit re-renders, the button is detached
7800
+ // between mousedown and mouseup and the `click` never fires at all (the
7801
+ // swallowed-first-click defect that fix exists for). Cancelling
7802
+ // `pointerdown` additionally suppresses the compatibility `mousedown` in
7803
+ // some engines, i.e. the very event that fix lives on. updateBlockDrag()
7804
+ // does the preventDefault() instead, once the gesture is definitely a drag.
7805
+ function armBlockDrag(e) {
7806
+ const handleEl = e.target && e.target.closest ? e.target.closest('.ed-handle') : null;
7807
+ if (!handleEl) return false;
7808
+ const blockEl = handleEl.closest('.ed-block');
7809
+ if (!blockEl) return false;
7810
+ const index = blockDropCandidates().indexOf(blockEl);
7811
+ if (index < 0) return false;
7812
+ blockDragState = {
7813
+ pointerId: e.pointerId,
7814
+ sourceIndex: index,
7815
+ // Carried for Tasks 3-7's own re-resolution cross-check only; the
7816
+ // ORDINAL above is the identity, for the renumbering reason spelt out
7817
+ // on `blockDragState`.
7818
+ sourceBlockId: blockEl.getAttribute('data-block-id'),
7819
+ startX: e.clientX, startY: e.clientY,
7820
+ dragging: false,
7821
+ dropTarget: null,
7822
+ captureEl: null,
7823
+ };
7824
+ return true;
7825
+ }
7826
+
7827
+ // Repositions the singleton line onto the current drop target. All four
7828
+ // dimensions are written on every move — the node is this gesture's alone
7829
+ // (see the class comment in lib/md2doc.js for why it is not the table's),
7830
+ // but the content column can be resized by a sidebar drag or a window
7831
+ // resize between two frames, so a partially-written box would leave a stale
7832
+ // width behind.
7833
+ function updateBlockDropIndicator(clientY) {
7834
+ const target = nearestBlockDropTarget(clientY);
7835
+ blockDragState.dropTarget = target;
7836
+ const els = blockDropCandidates();
7837
+ const spanEl = target.mode === 'append' ? els[els.length - 1] : els[target.blockIndex];
7838
+ const r = spanEl ? spanEl.getBoundingClientRect() : contentEl.getBoundingClientRect();
7839
+ blockDropIndicator.style.left = r.left + 'px';
7840
+ blockDropIndicator.style.width = r.width + 'px';
7841
+ blockDropIndicator.style.top = (target.y - 1) + 'px';
7842
+ blockDropIndicator.style.height = '3px';
7843
+ }
7844
+
7845
+ // Capture on documentElement at ENGAGE time, not at press time — the S3
7846
+ // selection drag's reasoning (captureBlockSelDrag()) applies verbatim here
7847
+ // and the table's does not. A press on a ⠿ that turns out to be a plain
7848
+ // click must not be interfered with AT ALL: it has to reach `click` and open
7849
+ // the menu. And the pressed node is a per-block button that rerenderAll()
7850
+ // and openRawEditor()'s restore() both re-create, so capturing on it would
7851
+ // pin a node the gesture can outlive.
7852
+ function captureBlockDrag(e) {
7853
+ const el = document.documentElement;
7854
+ if (el && typeof el.setPointerCapture === 'function') {
7855
+ try { el.setPointerCapture(e.pointerId); blockDragState.captureEl = el; }
7856
+ catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
7857
+ }
7858
+ }
7859
+
7860
+ function releaseBlockDragCapture(st) {
7861
+ if (st && st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
7862
+ try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
7863
+ }
7864
+ }
7865
+
7866
+ function updateBlockDrag(e) {
7867
+ if (!blockDragState || e.pointerId !== blockDragState.pointerId) return;
7868
+ if (tePointer) return; // a grip gesture owns this pointer
7869
+ // The browser can simply never deliver a pointerup (released over browser
7870
+ // chrome, over another window). The next move with no button held is the
7871
+ // only signal left that the gesture is over — the same guard
7872
+ // updateBlockSelDrag() carries, for the same reason.
7873
+ // S4 Task 3: endBlockDrag() is async now. Deliberately not awaited here —
7874
+ // `updateBlockDrag` is called from a `pointermove` listener that cannot
7875
+ // await, and this branch is the last-resort recovery for a pointerup the
7876
+ // browser never delivered. The teardown inside it is synchronous either
7877
+ // way; only the drop's own commit trails.
7878
+ if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockDrag(); return; }
7879
+ if (!blockDragState.dragging) {
7880
+ const dx = e.clientX - blockDragState.startX, dy = e.clientY - blockDragState.startY;
7881
+ if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
7882
+ blockDragState.dragging = true;
7883
+ captureBlockDrag(e);
7884
+ blockDropIndicator.hidden = false;
7885
+ // S4 Task 2b (user request, 2026-09-01): the ONLY feedback the source
7886
+ // block gets. `.ed-handle` is `opacity: 0` off-hover, so from the moment
7887
+ // the pointer leaves the block there is nothing under the cursor but the
7888
+ // indicator line. The ruling is a `cursor: grabbing` class on
7889
+ // `document.documentElement` — OUTSIDE `contentEl`, so the "mutate
7890
+ // nothing in the content DOM during a drag" invariant this whole gesture
7891
+ // rests on still holds, and every cancellation path stays unconditional.
7892
+ // The table drag's `ed-te-row-dragging` dim is deliberately NOT the
7893
+ // model: it dims a node INSIDE the content, and the burst's zero-edit
7894
+ // guard compares `contentEl`'s innerHTML, so the residue it leaves had
7895
+ // to be stripped by hand.
7896
+ //
7897
+ // ⚠ CORRECTED (S4 review round, 2026-09-01). The earlier wording here
7898
+ // and in §4.5 said documentElement AVOIDS that `class=""` residue. It
7899
+ // does not. classList.add() on an element with no `class` attribute
7900
+ // CREATES one and remove() only empties its value — measured in the same
7901
+ // Chromium: `<html class="">` after every engaged drag, every time. The
7902
+ // residue is not avoided, it is RELOCATED: `<html>` is a node nothing in
7903
+ // this file compares, `contentEl.innerHTML` is a string the zero-edit
7904
+ // guard reads on every commit. The ruling is unchanged (the cost really
7905
+ // is zero); only its stated reason was wrong.
7906
+ document.documentElement.classList.add(BLOCK_DRAGGING_CLASS);
7907
+ // Nothing else is touched here. The table's hover chrome (insert
7908
+ // bubbles, grips) is hidden by the rAF `mousemove` gate below, which is
7909
+ // where the row drag and the S3 selection drag both do it too; and the
7910
+ // ⠿ / + menus are left alone until endBlockDrag(), because closing one
7911
+ // detaches a node from INSIDE a block and would break the
7912
+ // "nothing is mutated during a drag" invariant this whole gesture rests
7913
+ // on.
7914
+ }
7915
+ e.preventDefault();
7916
+ updateBlockDropIndicator(e.clientY);
7917
+ }
7918
+
7919
+ // Shared teardown. Both exits below go through it, so a future drop added
7920
+ // to endBlockDrag() can never be reached from a cancellation path.
7921
+ function teardownBlockDrag() {
7922
+ if (!blockDragState) return null;
7923
+ const st = blockDragState;
7924
+ blockDragState = null;
7925
+ releaseBlockDragCapture(st);
7926
+ blockDropIndicator.hidden = true;
7927
+ // S4 Task 2b: removed HERE and nowhere else. T2 routed endBlockDrag() and
7928
+ // cancelBlockDrag() through this one function precisely so a later
7929
+ // addition inherits all five abort paths (pointerup, Escape,
7930
+ // pointercancel, window blur, the next pointerdown / every re-render) for
7931
+ // free. Unconditional on `.dragging`: classList.remove() of a class that
7932
+ // was never added is a no-op, and a gesture that ended before the
7933
+ // threshold never added it.
7934
+ document.documentElement.classList.remove(BLOCK_DRAGGING_CLASS);
7935
+ // A gesture that became a drag owns its own trailing `click`: released
7936
+ // outside every block it would reach wireBlockSelection()'s
7937
+ // switchAwayFrom(); released back over the ⠿ it started on it would OPEN
7938
+ // THE MENU the user was not asking for. Same one-shot flag the S3
7939
+ // selection drag sets, re-armed to false by the next pointerdown so a
7940
+ // gesture whose click never arrives cannot swallow a later one.
7941
+ if (st.dragging) blockSelClickSuppressed = true;
7942
+ return st;
7943
+ }
7944
+
7945
+ // The `pointerup` half. S4 Task 3 makes the drop REAL for a single block
7946
+ // that is not a list item; Tasks 4-7 widen it. The teardown above is
7947
+ // synchronous and happens FIRST, so the gesture is already over (indicator
7948
+ // down, capture released, `ed-block-dragging` off <html>) before a single
7949
+ // byte is considered — nothing the commit does can leave drag state behind.
7950
+ //
7951
+ // ASYNC, and the `pointerup` listener awaits it: the drop resolves an open
7952
+ // burst and re-renders, and a fire-and-forget promise would let a test (or
7953
+ // the next gesture) observe the document between the release and the
7954
+ // commit's own /api/render.
7955
+ async function endBlockDrag() {
7956
+ const st = teardownBlockDrag();
7957
+ if (!st || !st.dragging) return;
7958
+ // The menus are closed AFTER the gesture rather than at engage: closing
7959
+ // one detaches a node from inside a block, and mutating nothing during a
7960
+ // drag is what makes every cancellation path above unconditional.
7961
+ closeGutterMenu();
7962
+ closeInsertMenu();
7963
+ await performBlockDrop(st);
7964
+ }
7965
+
7966
+ // ── S4 Task 3: the drop (spec §4.5) ───────────────────────────────────
7967
+ // Everything up to here has been chrome. This is where the ⠿ drag writes
7968
+ // bytes, through the pure commitBlockMove() above: ONE commitRangeEdit over
7969
+ // the min..max span, hence exactly one Ctrl+Z.
7970
+ //
7971
+ // The pressed block is named by ORDINAL, never by node and never by id —
7972
+ // the table drop's own rule. `blockmap.js` renumbers ids 0..n-1 on every
7973
+ // render, so a captured id names the target's NEIGHBOUR after any commit
7974
+ // that changes the block count, and every node in the document is detached
7975
+ // by a re-render. The ordinal is resolved in the LIVE DOM here, before
7976
+ // resolveGutterOperands() is allowed to commit anything.
7977
+ //
7978
+ // The DESTINATION needs the same protection and gets it one step earlier:
7979
+ // its identity is captured BEFORE resolveGutterOperands(), whose
7980
+ // switchAwayFrom() may commit some other block's dirty burst and re-render.
7981
+ // reresolveBlockEl() then proves the block sitting at that start line
7982
+ // afterwards really is the same one, exactly as the source's own re-resolve
7983
+ // does; when it cannot, the gesture is DROPPED with a banner rather than
7984
+ // completed against a block the user did not point at.
7985
+ async function performBlockDrop(st) {
7986
+ const target = st.dropTarget;
7987
+ // ⚠ S4 Task 6 audited this for §3.6 (「靜默不動作是缺陷」) and it is the one
7988
+ // silent exit in this function that NO GESTURE CAN REACH: performBlockDrop()
7989
+ // is called only from endBlockDrag()'s `st.dragging` branch, engaging sets
7990
+ // `dragging` and then calls updateBlockDropIndicator(), which assigns
7991
+ // `dropTarget` UNCONDITIONALLY from nearestBlockDropTarget() — and that
7992
+ // function has no null answer at all, falling through to
7993
+ // `{mode:'append'}` when no block's mid-line is below the pointer. Kept as
7994
+ // a defensive guard, not as a behaviour.
7995
+ if (!target) return; // engaged but never moved onto a target — nothing to do
7996
+ const els = blockDropCandidates();
7997
+ const sourceEl = els[st.sourceIndex];
7998
+ const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
7999
+ if (!sourceEl || (target.mode !== 'append' && !destEl)) {
8000
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
8001
+ return;
8002
+ }
8003
+ const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
8004
+ // ...and the SAME question resolveGutterOperands() asks about the source,
8005
+ // asked about the destination, BEFORE the commit that answers it.
8006
+ //
8007
+ // S4 review round (2026-09-01), MEDIUM: a drop onto a block with an open
8008
+ // DIRTY burst was ALWAYS refused. Measured on
8009
+ // '# Doc\n\nalpha\n\nbravo\n\ncharlie\n' — type ' EDITED' into
8010
+ // `charlie`, drag `alpha` onto charlie's top edge — and the answer was
8011
+ // '文件已更新,請重試這個操作' with the edit committed and the move not.
8012
+ //
8013
+ // It is an ASYMMETRY, not a race, and it could not be anything else:
8014
+ // reresolveBlockEl()'s fingerprint is the block's SOURCE, and the commit
8015
+ // that changed that source is this drop's OWN resolveGutterOperands() →
8016
+ // switchAwayFrom(). The fingerprint is therefore GUARANTEED to miss. The
8017
+ // source side has had reresolveBlockElAfterSelfCommit() for exactly this
8018
+ // since S2; this is its destination-side counterpart, gated the same way
8019
+ // — on ownsOpenSession() read BEFORE the commit, because `currentBurst` is
8020
+ // gone afterwards — so reresolveBlockEl() keeps its fingerprint for
8021
+ // everybody else. Deliberately NOT a widening of that function: S2's own
8022
+ // note says the narrowed variant exists precisely so no other caller loses
8023
+ // the fingerprint.
8024
+ const destSelfSession = destEl ? ownsOpenSession(destEl) : false;
8025
+ // The shared gutter preamble — identity capture, switchAwayFrom(), the
8026
+ // re-resolve after a commit that re-rendered, the no-source-line refusal
8027
+ // and §3.3's membership. Not re-implemented here: 「不得另寫一條」.
8028
+ const operands = await resolveGutterOperands(sourceEl);
8029
+ if (!operands) return;
8030
+ let liveDestEl = destEl;
8031
+ if (destEl && !document.body.contains(destEl)) {
8032
+ liveDestEl = reresolveBlockEl(destIdentity) ||
8033
+ (destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
8034
+ if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8035
+ }
8036
+ // ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
8037
+ // §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
8038
+ // this read `if (operands.batch) return;` — the last two SILENT shapes in
8039
+ // the drag path, which §3.6 calls a defect in so many words
8040
+ // (「靜默不動作是缺陷」). From here down `opEls` is a span of one OR MORE
8041
+ // blocks and every path takes it whole; nothing loops over it, for the
8042
+ // reason convertBlockViaMenu() states — a commit between two members
8043
+ // re-renders and invalidates every id in between.
8044
+ const opEls = operands.els;
8045
+ const opRecs = operands.recs;
8046
+ // ── the FIRST home position, and it is shared by every kind and size ──
8047
+ // A drop that lands where the block already is is a silent byte no-op —
8048
+ // NOT a refusal and NOT a dropped gesture. It has to be answered before
8049
+ // every gate, and MEASURED, because the gates get it wrong on their own:
8050
+ // `before-block <sourceIndex>` — released on the block's own top edge,
8051
+ // which is exactly what the drag's "released where it started" scenario
8052
+ // drives — makes the destination the SOURCE, and the destination's index
8053
+ // in the source-less array below is then -1, i.e. the
8054
+ // '文件已更新,請重試這個操作' banner, for a gesture in which nothing
8055
+ // whatsoever went wrong. Caught by driving a real gesture and reading the
8056
+ // banner back; the byte assertions could not see it, because the file is
8057
+ // untouched either way. For a SET the same question is membership: any
8058
+ // member's top edge is the set's own footprint. The li path below has a
8059
+ // SECOND home position of its own — see performListItemDrop().
8060
+ if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
8061
+ const shape = spanListKinds(opEls);
8062
+ // §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
8063
+ // A mixed span would need a blank-line rule at the run/non-run seam — the
8064
+ // li half has to stay tight while the non-li half has to keep its
8065
+ // separators, and inside ONE commit range those two demands conflict. §3.6
8066
+ // records that the spec does not have that rule and that inventing it in
8067
+ // the last stage repeats the §3.4 marker-width error, so this refuses with
8068
+ // 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
8069
+ //
8070
+ // DELIBERATELY ABOVE the second home position, which is the one place this
8071
+ // function departs from Task 3's "answer a home position before every
8072
+ // gate" rule, and the departure is the point: a seam objection depends on
8073
+ // where the user AIMED, so refusing a put-it-back gesture with one would
8074
+ // be a banner for a gesture in which nothing went wrong. MIXED is a
8075
+ // property of the SET — it gives the same answer at every destination, so
8076
+ // there is no aim that would have worked and saying so is the honest
8077
+ // outcome. (The drop-on-a-member case is already out, above.)
8078
+ if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
8079
+ // S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
8080
+ // relocated verbatim (markers and ordinals are run-global), so the whole
8081
+ // run is re-serialized in the new span order instead — still ONE
8082
+ // commitRangeEdit, still one Ctrl+Z. `shape.allLi` rather than the first
8083
+ // member's type: a set is all-li or it is mixed, and mixed is already out.
8084
+ // Everything below this line is the non-li move.
8085
+ if (shape.allLi) {
8086
+ await performListItemDrop(opEls, liveDestEl);
8087
+ return;
8088
+ }
8089
+ // ── the SECOND home position, still before every gate ──────────────
8090
+ // `before-block <sourceIndex + 1>` — the seam immediately below, the same
8091
+ // position on screen — is a no-op planBlockMove() answers null for, but
8092
+ // the list-seam gate below would have refused it OUT LOUD first whenever
8093
+ // the block sits between two list items. (The first home position is
8094
+ // answered above, before the li branch, because both kinds share it.)
8095
+ const live = blockDropCandidates();
8096
+ // The set's own two edges. `si` is its FIRST member and `sj` its LAST —
8097
+ // the source seam is what stands OUTSIDE those, and asking `si + 1` for it
8098
+ // would name the set's second member on any set of more than one.
8099
+ const si = live.indexOf(opEls[0]);
8100
+ const sj = live.indexOf(opEls[opEls.length - 1]);
8101
+ const rest = live.filter((el) => opEls.indexOf(el) === -1);
8102
+ const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
8103
+ if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8104
+ // §3.3's set is contiguous in block order — resolveGutterOperands()'s own
8105
+ // gate — so its members' lines plus the separators between them are ONE
8106
+ // range, which is what makes a batch move the same single commitRangeEdit
8107
+ // (and the same single Ctrl+Z) a one-block move is. See spanMoveRange().
8108
+ const srcRec = spanMoveRange(opRecs);
8109
+ const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
8110
+ if (!srcRec || (liveDestEl && !destRec)) {
8111
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
8112
+ return;
8113
+ }
8114
+ // A destination that owns no source line has an INVERTED range, and its
8115
+ // startLine names the line its OWNER occupies — inserting there would put
8116
+ // the block inside somebody else's territory.
8117
+ //
8118
+ // ⚠ S4 Task 6 MEASURED that this is REACHABLE, and that Task 3's comment
8119
+ // here ("unreachable while the seam gate below holds") was wrong. It said
8120
+ // so on the grounds that every no-line phantom this model produces is a
8121
+ // li — true, and beside the point: the seam gate refuses a destination
8122
+ // whose BOTH neighbours are li of one run, and a phantom's upper
8123
+ // neighbour is very often not a li at all. Driven as a real gesture on
8124
+ // '# Doc\n\npara\n\n- - b\n- c\n\ntail\n' (blockmap:
8125
+ // heading{1,1} | para{3,3} | li{5,4} PHANTOM | li{5,5} | li{6,6} |
8126
+ // para{8,8}): the phantom has a box of its own (measured top 190,
8127
+ // bottom 215 at 1400x900), so nearestBlockDropTarget() names it like any
8128
+ // other block; `rest[di - 1]` is the PARAGRAPH, so blockMoveSeamRefusal()
8129
+ // answers null; and planBlockMove() answers a REAL plan for it
8130
+ // ('# Doc\n\npara\n\ntail\n\n- - b\n- c\n'), so it is not a home
8131
+ // position either. Dragging `tail` onto that phantom therefore did
8132
+ // nothing, wrote nothing and said nothing — §3.6's 「靜默不動作是缺陷」,
8133
+ // and the third silent shape this task owed on top of the two the plan
8134
+ // named.
8135
+ //
8136
+ // The banner is the DESTINATION seam's, not a fifth constant: the line
8137
+ // this drop names belongs to the phantom's own nested item, so the block
8138
+ // really would land between an item and its sublist, which is exactly
8139
+ // what 無法把區塊放進清單項目之間 says. The remedy it gives (aim
8140
+ // somewhere else) is the correct one.
8141
+ if (destRec && destRec.endLine < destRec.startLine) {
8142
+ refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
8143
+ return;
8144
+ }
8145
+ // The second home position (see above): `null` means "already there".
8146
+ // Asked BEFORE the seam gate so a put-it-back gesture is silent rather
8147
+ // than refused. planBlockMove() is pure, so asking twice costs nothing.
8148
+ if (!planBlockMove(lines, blocks, srcRec, destRec)) return;
8149
+ // Both seams MEASURED with marked.lexer, not reasoned about:
8150
+ // destination — '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para`
8151
+ // spliced between the two items lexes as list(1 item) | paragraph |
8152
+ // list(1 item): ONE two-item run became TWO one-item runs, and the
8153
+ // blank lines rule 3 emits are §4.3's looseness trap;
8154
+ // source — '# Doc\n\n- a\n\npara\n\n- b\n' with `para` lifted out
8155
+ // leaves '- a\n\n- b\n', which lexes as ONE list, loose === TRUE.
8156
+ // Every item then renders as <p>, serializeBlocks() reports 'P' for
8157
+ // each, and the whole run degrades read-only WITH NO BANNER.
8158
+ // S4 Task 5 NARROWED the destination half of this and deliberately left
8159
+ // the source half wide; blockMoveSeamRefusal()'s own header carries the
8160
+ // full enumeration and the measurement behind every row of it. The short
8161
+ // version: an insertion can only SPLIT, so the destination only has to
8162
+ // refuse a seam INSIDE one run; a removal MERGES, and the model does not
8163
+ // carry the raw bytes (the bullet character, the leading two spaces) that
8164
+ // decide whether two runs about to become adjacent stay two lists.
8165
+ //
8166
+ // The descriptor is built from listRunOf() — §3.4 rule 2's scope — so a
8167
+ // nested child and its parent answer the same run key, which is what
8168
+ // stops "a paragraph dropped between an item and its own sublist" from
8169
+ // reading as a boundary between two separate lists.
8170
+ const seamDesc = (el) => {
8171
+ if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
8172
+ const r = listRunOf(el);
8173
+ return { runKey: r.length ? r[0].getAttribute('data-block-id') : null };
8174
+ };
8175
+ const seamRefusal = blockMoveSeamRefusal(
8176
+ { prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
8177
+ { prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
8178
+ if (seamRefusal === 'source') {
8179
+ refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
8180
+ return;
8181
+ }
8182
+ if (seamRefusal === 'destination') {
8183
+ refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
8184
+ return;
8185
+ }
8186
+ const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
8187
+ // commitRangeEdit()'s own unchanged-text answer, or a refused inverted
8188
+ // range. Neither pushed an op, so there is nothing to render or roll back.
8189
+ //
8190
+ // ⚠ S4 Task 6 audited this too, and corrected Task 3's claim that neither
8191
+ // is reachable. The inverted-range half is not (planBlockMove() has
8192
+ // already answered a real plan). The UNCHANGED-TEXT half is: MEASURED on
8193
+ // 'same\n\nsame\n\ntail\n' with the first paragraph moved past the
8194
+ // second, planBlockMove() answers a genuine plan and the text it produces
8195
+ // is the file byte-for-byte, because the two blocks are identical. That
8196
+ // is a real byte no-op — the document after the gesture IS the document
8197
+ // before it — so it stays silent for Task 3's own reason. A banner here
8198
+ // would tell a user that a move they can SEE succeeded had failed.
8199
+ if (!result.op) return;
8200
+ const prevLines = lines;
8201
+ lines = result.lines;
8202
+ // §3.3's collapse, declared IMMEDIATELY before the render and never
8203
+ // earlier: the moved block's range in the NEW document, which is where a
8204
+ // standing selection must end up. declareCollapse()'s own `if
8205
+ // (blockSelection)` guard is what stops this single-block gesture from
8206
+ // INVENTING a selection when none was standing.
8207
+ declareCollapse(result.newRange);
8208
+ const okRender = await safeRerenderAll();
8209
+ if (!okRender) {
8210
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
8211
+ }
8212
+ }
8213
+
8214
+ // ── S4 Task 4: the drop of a LIST ITEM, inside its own run (spec §4.5) ──
8215
+ //
8216
+ // Task 3's drop returned SILENTLY for a li. §3.6 is explicit that
8217
+ // 「靜默不動作是缺陷」, so after this function a li drag either MOVES or
8218
+ // refuses WITH A BANNER; the only silent outcome left is a home position,
8219
+ // which is Task 3's own ruling (a drop where the block already is is a byte
8220
+ // no-op, not a refusal) and is answered in two places here.
8221
+ //
8222
+ // THE SHAPE IS duplicateListItems()'s, and the recon named it as the only
8223
+ // existing path that already expresses a reorder as ONE commit:
8224
+ // * capture `presetRange` BEFORE mutating — runRangeOfBlocks() takes the
8225
+ // FIRST and LAST members of the array it is handed on the written
8226
+ // assumption that the array is in DOCUMENT order, which the reordered
8227
+ // span is not;
8228
+ // * build the REORDERED SPAN ARRAY (serializeBlocks() emits in array
8229
+ // order, and rebuilds its width/counter/type stacks as it walks — the
8230
+ // output is entirely determined by that order);
8231
+ // * mutateListRun() to move the node, with the focusout suppressed;
8232
+ // * commitListStructure(span, …, {presetRange, carryOver}), where
8233
+ // `carryOver` is bystanderCarryOver(span) — built AFTER the reorder and
8234
+ // BEFORE `blocks` is rebuilt — so every block's ORIGINAL bytes are
8235
+ // replayed by id and nothing is re-escaped ('~5px' would otherwise come
8236
+ // back '\~5px'). Only the marker and the leading columns are re-stated,
8237
+ // which is exactly §3.8's renumbering and §3.4's colDelta.
8238
+ // One commitRangeEdit inside commitListStructure() is one undo op, which is
8239
+ // §3.4's requirement for one gesture.
8240
+ //
8241
+ // `data-list-start` is a POSITION property, not an item property. It is the
8242
+ // only carrier of "marked's lexer opened a list token here" (§3.8 rule (d))
8243
+ // and serializeBlocks() resets its ordinal counter on it, so a reorder that
8244
+ // changes which item stands FIRST has to carry it across — the mirror image
8245
+ // of duplicateListItems() stripping it from a copy. MEASURED, on
8246
+ // '1. alpha / 2. bravo / 3. charlie' with charlie dragged to the head:
8247
+ // without the transfer the attribute stays on `alpha`, which is now the
8248
+ // span's SECOND block, and the run comes back
8249
+ // '1. charlie / 1. alpha / 2. bravo'.
8250
+ //
8251
+ // SCOPE — and it is exactly the task's title, "within its own run". The
8252
+ // destination must be a slot between the item's own §3.8 RUN SIBLINGS, or
8253
+ // the slot immediately past the last of them. Everything else is a
8254
+ // cross-boundary move: out of the list entirely, into a nested sublist, into
8255
+ // a different run. Task 5 owns that enumeration and the ruling it implements
8256
+ // is already made — refused with a banner in 3.0.0 — so they are refused
8257
+ // here with Task 5's own constant rather than half-implemented.
8258
+ //
8259
+ // S4 Task 6 widened `liEl` to `liEls` — §3.3's operand set, one OR MORE list
8260
+ // items in document order. The whole function is the same commit: the set is
8261
+ // contiguous in block order (resolveGutterOperands()'s gate), and `run` is a
8262
+ // contiguous slice of the document (listRunOf()'s own contract), so the
8263
+ // members occupy CONSECUTIVE slots of `run` and "move them" is one call to
8264
+ // reorderSpanRange() with a count instead of two paths.
8265
+ async function performListItemDrop(liEls, destEl) {
8266
+ const liEl = liEls[0];
8267
+ const lastLiEl = liEls[liEls.length - 1];
8268
+ // §3.4 rule 2's scope, and the span commitListStructure() re-serializes:
8269
+ // the OUTERMOST run reachable from this item plus every descendant of its
8270
+ // members. Not the item's own sibling run — a span that STARTS at indent 2
8271
+ // has no width recorded for depths 0 and 1 and would be emitted with no
8272
+ // indent prefix at all, i.e. committing it would de-nest the whole thing.
8273
+ const run = listRunOf(liEl);
8274
+ const srcIdxs = liEls.map((el) => run.indexOf(el));
8275
+ const srcIdx = srcIdxs[0];
8276
+ if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8277
+ // A member outside the grip's own commit span means the set straddles TWO
8278
+ // runs — contiguity in `blocks` does not imply one run, because two
8279
+ // adjacent runs separated by a delimiter change ('- a' / '1. b') are
8280
+ // adjacent blocks with no phantom between them. Same answer
8281
+ // convertBlockViaMenu() gives, through the same message: the commit
8282
+ // re-serializes ONE run's line range and a member outside it would be
8283
+ // rewritten by a range that does not cover it.
8284
+ if (srcIdxs.indexOf(-1) !== -1) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
8285
+ // ...and they must be CONSECUTIVE slots of it. Guaranteed today by
8286
+ // spanIsContiguous() plus listRunOf() returning a contiguous slice, and
8287
+ // checked anyway rather than asserted in prose: reorderSpanRange() takes a
8288
+ // `from` and a `count`, so a gap here would silently drag a NON-member
8289
+ // along with the set.
8290
+ for (let k = 1; k < srcIdxs.length; k++) {
8291
+ if (srcIdxs[k] !== srcIdxs[k - 1] + 1) { refuseStructuralListEdit(BATCH_GAP_MESSAGE); return; }
8292
+ }
8293
+ const all = blockDropCandidates();
8294
+ // ── the SECOND home position ───────────────────────────────────────
8295
+ // `before-block <sourceIndex + 1>` is the seam immediately below the item,
8296
+ // which is the same place on screen as its own top edge. For an item WITH
8297
+ // CHILDREN that next block is its own first child, and "before my own
8298
+ // first child" is precisely where it already is; for the run's LAST item
8299
+ // it is the block AFTER the run, which must be read as "already there"
8300
+ // and not as a boundary crossing. Answered before every gate, for Task 3's
8301
+ // reason: a refusal here would be a banner for a gesture in which nothing
8302
+ // went wrong.
8303
+ const afterSrcEl = all[all.indexOf(lastLiEl) + 1] || null;
8304
+ if (destEl ? destEl === afterSrcEl : all[all.length - 1] === lastLiEl) return;
8305
+ // ── §4.3's run-wide gate ───────────────────────────────────────────
8306
+ // 「轉換/建立副本/刪除/拖曳一律在 mutation 前走
8307
+ // listRunSupportsStructuralEdit()」. There is no shared helper — the four
8308
+ // existing call sites each make the call themselves — so this one does
8309
+ // too. Its input is §3.4 rule 2's scope, which is exactly what listRunOf()
8310
+ // returns. Ahead of every OTHER refusal, the convention convertBlockViaMenu()
8311
+ // states: a run that cannot be structurally edited at all must say so,
8312
+ // rather than report whichever narrower objection is asked next.
8313
+ //
8314
+ // `columnOnly` is the honest option, and it is the same argument
8315
+ // insertBlockBelow()'s li path and the rule-2 merge already make: a
8316
+ // reorder rewrites NO item's CONTENT and NO item's LINE COUNT. The only
8317
+ // bytes that move in any member are its marker and its leading columns —
8318
+ // §3.8's renumbering, applied to a replayed bystander as §3.4's colDelta —
8319
+ // which is the criterion listRunSupportsStructuralEdit() documents.
8320
+ // Without it a single hard-wrapped item anywhere in the run would veto
8321
+ // every drag, which on this repo's own CHANGELOG.md is every run (80.6% of
8322
+ // its list items are hard-wrapped).
8323
+ if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
8324
+ refuseStructuralListEdit();
8325
+ return;
8326
+ }
8327
+ // ── the destination must be a slot in the item's OWN §3.8 run ──────
8328
+ // `sibs` is that run: the same-depth members listRunOf() walked out from.
8329
+ // The two legal shapes are "before sibling S" and "past the last
8330
+ // sibling's whole subtree" — the latter being the ONLY way to name the
8331
+ // end of a run that something follows, since the drop target can only ever
8332
+ // name a block, and the block it names there is outside the run.
8333
+ const sibs = runBlocksOf(liEl);
8334
+ const lastSib = sibs[sibs.length - 1];
8335
+ const sibTail = subtreeBlocksAfter(lastSib, Number(lastSib.getAttribute('data-indent')) || 0);
8336
+ const runEndEl = sibTail.length ? sibTail[sibTail.length - 1] : lastSib;
8337
+ const afterRunEl = all[all.indexOf(runEndEl) + 1] || null;
8338
+ let insertAt = -1;
8339
+ if (destEl === null) {
8340
+ // The {mode:'append'} target is "after the document's last block", so it
8341
+ // names the end of this run only when the run ends the document.
8342
+ if (afterRunEl === null) insertAt = run.indexOf(runEndEl) + 1;
8343
+ } else if (sibs.indexOf(destEl) !== -1) {
8344
+ insertAt = run.indexOf(destEl);
8345
+ } else if (destEl === afterRunEl) {
8346
+ insertAt = run.indexOf(runEndEl) + 1;
8347
+ }
8348
+ // All four out-of-run shapes land here: a slot between two non-li, a
8349
+ // DIFFERENT run, the middle of a run of another list type, and the
8350
+ // {mode:'append'} target when a block follows the run. Task 5 gave them
8351
+ // their own message — the remedy is "stay inside your own list", which
8352
+ // neither seam message says.
8353
+ if (insertAt < 0) { refuseStructuralListEdit(BLOCK_MOVE_OUT_OF_RUN_MESSAGE); return; }
8354
+ // `null` is the order-is-unchanged answer — the two home positions above
8355
+ // reach this by another road (an item whose next block is a sibling), and
8356
+ // it stays SILENT for the same reason.
8357
+ const order = reorderSpanRange(run.length, srcIdx, srcIdxs.length, insertAt);
8358
+ if (!order) return;
8359
+ // Captured BEFORE the reorder, and this is the whole reason duplicateListItems()
8360
+ // captures it too: runRangeOfBlocks() reads the first and last members of
8361
+ // the ARRAY, and the reordered span's first member is not the run's first
8362
+ // line.
8363
+ const range = runRangeOfBlocks({ lines, blocks, stack }, run);
8364
+ if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
8365
+ const span = order.map((i) => run[i]);
8366
+ // ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
8367
+ //
8368
+ // §4.5: the moved item keeps its own `data-indent` (nothing here writes
8369
+ // one) and 「子項留在原地(依 3.4 位移夾取)」 — the children it leaves
8370
+ // behind are re-anchored by §3.4.
8371
+ //
8372
+ // THE MEASUREMENT THIS IS BUILT ON, and it is the correction of Task 4's.
8373
+ // Task 4 measured that handing `applyIndentClamp()` the REORDERED span
8374
+ // clamps nothing — true, and for a reason that names its own remedy:
8375
+ // rule 2's scope starts after the operated block's NEW index, while the
8376
+ // orphans are left behind at its OLD one. A move IS a REMOVAL at the old
8377
+ // index plus an INSERTION at the new one, so the orphans are the removal's
8378
+ // business and `{ removed: true }` — the mode the ⠿ delete has used since
8379
+ // S1 — is exactly the right question, asked of `run` in DOCUMENT order
8380
+ // (which it still is: nothing has touched the DOM yet). `clampIndents()`
8381
+ // reports no indent for a removed block, so the travelling members' own
8382
+ // depths are never rewritten by this; the reorder below then re-inserts
8383
+ // them at the destination unchanged. NOTHING in indent-clamp.js changed,
8384
+ // no new mode, no §3.4 amendment.
8385
+ //
8386
+ // Two things the sweep settled that prose could not (every legal indent
8387
+ // array of length 2..6 and depth 0..3 × every operand-set size × every
8388
+ // destination the gate above admits = 4067 drops; the pin is in
8389
+ // test/editor-client.test.js):
8390
+ //
8391
+ // 1. §4.5 also says the moved block is 「落點使其非法時夾到合法值」. That
8392
+ // has NO reachable case: 0 of the 4067 leave the moved block's own
8393
+ // indent illegal, because the destination gate above only ever admits
8394
+ // a slot among its own same-depth siblings and the block standing
8395
+ // before such a slot can always parent it. If that gate is ever
8396
+ // widened (3.1.0) this becomes owed — and rule 1 of clampIndents()
8397
+ // already answers it, on the span, at the new index.
8398
+ // 2. The clamp does not answer everything. 14 of the 4067 stay
8399
+ // unanchored, all of them BATCH sets whose last member is SHALLOWER
8400
+ // than the block they land before — the INSERTION half, at the new
8401
+ // index, for which §3.4 has no rule. Those keep Task 4's refusal,
8402
+ // narrowed to them and re-worded with it.
8403
+ //
8404
+ // ORDER MATTERS: compute, judge, and only then write. A refusal that has
8405
+ // already written `data-indent` leaves a half-clamped DOM behind on a
8406
+ // gesture that wrote no bytes — the same screen/model disagreement the
8407
+ // clamp exists to prevent, produced by the clamp itself.
8408
+ const clamp = computeIndentClamp(run, liEls, spanMinIndent(liEls), { removed: true });
8409
+ const clamped = new Map();
8410
+ (clamp || []).forEach((r) => { clamped.set(r.el, r.indent); });
8411
+ if (!spanIndentsAreAnchored(span.map((el) => ({
8412
+ type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
8413
+ indent: clamped.has(el) ? clamped.get(el)
8414
+ : (Number(el.getAttribute('data-indent')) || 0),
8415
+ })))) {
8416
+ refuseStructuralListEdit(BLOCK_MOVE_ORPHAN_MESSAGE);
8417
+ return;
8418
+ }
8419
+ // S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
8420
+ // measurement to show it. Deleting the line left all 39 S4 scenarios green
8421
+ // — the four T7 ones named after the clamp included — because every one of
8422
+ // them moves a SINGLE item, and for `count === 1` the write can never
8423
+ // change a byte: serializeBlocks() rebuilds its width stack as it walks
8424
+ // (`widths.length = indent + 1` after every block), so an over-deep
8425
+ // `data-indent` is emitted at its anchor's own column, which is the column
8426
+ // the clamp would have written. Re-run over the whole 4067-drop space with
8427
+ // the real serializeBlocks() on both sides: 0 single-item drops differ, 53
8428
+ // BATCH drops differ on a `ul` and 123 on an `ol`. The smallest is indents
8429
+ // 0,0,1,2,1 with {1,2} moved past the run — without the write the orphan
8430
+ // that kept its departed parent's depth is ADOPTED by a block the user
8431
+ // never touched, which is rule 3's own worked failure. Pinned as bytes by
8432
+ // the RV2 runtime scenario and as counts in test/editor-client.test.js.
8433
+ writeIndentClamp(clamp);
8434
+ const sibsAfter = span.filter((el) => sibs.indexOf(el) !== -1);
8435
+ mutateListRun(() => {
8436
+ // `data-list-start` follows the run's HEAD, not the item — see this
8437
+ // function's own header for the measured '1. charlie / 1. alpha' that
8438
+ // dropping this produces. Only the moved item can change which sibling
8439
+ // stands first, so this is the only pair that can need it; a NESTED
8440
+ // run's own list-start belongs to a member nothing moved and is left
8441
+ // exactly where it is.
8442
+ if (sibsAfter[0] !== sibs[0] && sibs[0].getAttribute('data-list-start') === '1') {
8443
+ sibs[0].removeAttribute('data-list-start');
8444
+ sibsAfter[0].setAttribute('data-list-start', '1');
8445
+ }
8446
+ // `insertAt` indexes `run`, which is still in document order at this
8447
+ // point — the DOM has not been touched yet. `insertAt === run.length`
8448
+ // cannot be the moved item's own slot (that is the home position, out
8449
+ // above), so the reference node is safe to read off the run's last
8450
+ // member.
8451
+ const refEl = insertAt < run.length ? run[insertAt] : run[run.length - 1].nextSibling;
8452
+ // In the set's OWN order, each before the same reference node: that
8453
+ // leaves them consecutive and in document order at the destination,
8454
+ // which is what reorderSpanRange() already said the span would be. A
8455
+ // loop that inserted each before the PREVIOUS one would reverse the set.
8456
+ liEls.forEach((el) => { el.parentNode.insertBefore(el, refEl); });
8457
+ });
8458
+ // AFTER the reorder and BEFORE `blocks` is rebuilt, which is the window
8459
+ // bystanderCarryOver() documents. No `mutatedEl`: nothing in this span had
8460
+ // its CONTENT rewritten — the map is what keeps every member byte-
8461
+ // identical to the file, the moved one included.
8462
+ const carryOver = bystanderCarryOver(span);
8463
+ // §3.3's collapse, declared immediately before the render inside
8464
+ // commitListStructure(). declareCollapse()'s own `if (blockSelection)`
8465
+ // guard is what stops this single-block gesture from INVENTING a
8466
+ // selection when none was standing; when one WAS, resolveGutterOperands()
8467
+ // has already collapsed it onto this item, so it has to follow the item
8468
+ // to its new lines.
8469
+ declareCollapse(movedLiRangeAfterReorder(span, liEls, carryOver, range));
8470
+ await commitListStructure(span, null, false,
8471
+ { presetRange: range, carryOver: carryOver });
8472
+ }
8473
+
8474
+ // The moved item's line range in the run AS IT IS ABOUT TO BE COMMITTED.
8475
+ //
8476
+ // Deliberately NOT runLineOfBlock(), and this is a measurement that
8477
+ // contradicts the obvious reuse: that function derives the run's start line
8478
+ // with runRangeOfBlocks(runEls), which takes the FIRST member of the array —
8479
+ // correct for every existing caller, whose array is in document order, and
8480
+ // wrong here by construction, because the reordered span's first member is
8481
+ // whichever item was just moved to the head. The rest of its arithmetic is
8482
+ // reproduced: `lineMeta` carries one entry per EMITTED line naming the block
8483
+ // that owns it, so a block's range is the run of entries bearing its id —
8484
+ // counted, never assumed to be one, because a hard-wrapped item replays
8485
+ // several.
8486
+ //
8487
+ // S4 Task 6: `liEls` is §3.3's operand set, one or more. The members travel
8488
+ // together and stay consecutive, so their emitted lines are one contiguous
8489
+ // run and the answer is the FIRST line of the first member to the LAST line
8490
+ // of the last — which is exactly §3.3's 「操作結果所涵蓋的行區間」. Taking
8491
+ // the first member's range alone would collapse a three-block drag onto one
8492
+ // tinted block.
8493
+ function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
8494
+ const ids = liEls.map((el) => el.getAttribute('data-block-id'));
8495
+ if (ids.indexOf(null) !== -1) return null;
8496
+ const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
8497
+ let first = -1;
8498
+ let last = -1;
8499
+ lineMeta.forEach((m, i) => {
8500
+ if (ids.indexOf(m.blockId) === -1) return;
8501
+ if (first < 0) first = i;
8502
+ last = i;
8503
+ });
8504
+ if (first < 0) return null;
8505
+ return { startLine: range.startLine + first, endLine: range.startLine + last };
8506
+ }
8507
+
8508
+ // Unconditional teardown of the in-flight ⠿ gesture — called from FIVE
8509
+ // places, exactly mirroring cancelTeDrag()'s own list: the Escape
8510
+ // prologue's first rungs, `pointercancel`, window `blur`, the defensive
8511
+ // clear at the top of the next `pointerdown`, and every re-render path
8512
+ // (rerenderAll() / resolveBurst()). Nothing was mutated, so there is
8513
+ // nothing to revert and no state this is unsafe to be called from — the
8514
+ // null-out is unconditional on `blockDragState` being set and is NOT gated
8515
+ // on `.dragging`, which is the review fix cancelTeDrag() carries too (a
8516
+ // burst resolution landing during the pressed-but-pre-threshold window used
8517
+ // to leave the table's state pointing at a row rerenderAll() was about to
8518
+ // detach).
8519
+ function cancelBlockDrag() {
8520
+ teardownBlockDrag();
8521
+ }
8522
+
7127
8523
  document.addEventListener('pointerdown', (e) => {
7128
8524
  if (e.button !== 0) return;
7129
8525
  if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
@@ -7134,6 +8530,10 @@
7134
8530
  // capture) BEFORE starting a new one, so a stuck drag can never survive
7135
8531
  // into the next gesture and a fresh pointerdown always starts clean.
7136
8532
  if (tePointer) cancelTeDrag();
8533
+ // S4 Task 2: same defensive reasoning again for the ⠿ block drag — a prior
8534
+ // gesture whose pointerup/pointercancel never arrived must not survive into
8535
+ // this one. Nothing was mutated, so this is always a clean no-op teardown.
8536
+ if (blockDragState) cancelBlockDrag();
7137
8537
  // S3 Task 4: same defensive reasoning one line up, for the block-selection
7138
8538
  // press — and the flag that suppresses a gesture's own trailing click is
7139
8539
  // re-armed here so a gesture whose click never arrived cannot swallow the
@@ -7163,6 +8563,26 @@
7163
8563
  // still wins" true by construction: a second, competing pointerdown
7164
8564
  // listener would have to re-derive the hit test, and would race this
7165
8565
  // one's menu dismiss above depending on registration order.
8566
+ // S4 Task 2 (spec §4.5): the ⠿ drag arms HERE, ahead of both selection
8567
+ // entries, and claims the pointer when it owns it. It has to run before
8568
+ // armBlockSelDrag(), whose first line returns for `.ed-handle` (a member
8569
+ // of ED_SEL_GESTURE_CHROME) — which is why a press on the ⠿ recorded no
8570
+ // pointer state at all before this task. beginShiftClickSelection()
8571
+ // returns for the same target, so ordering it above the Shift branch too
8572
+ // changes nothing for any press that is not on a ⠿: armBlockDrag()
8573
+ // returns false for every other target and falls straight through.
8574
+ //
8575
+ // S4 review round (2026-09-01) — RECORDED, because nobody had. It DOES
8576
+ // change one thing for a press that IS on a ⠿: armBlockDrag() reads no
8577
+ // modifier, so Shift / Ctrl / Alt + ⠿ now starts a drag, where before S4
8578
+ // it did nothing at all until release. KEPT, deliberately: the table's
8579
+ // row and column grips take the pointer with no modifier check either
8580
+ // (see the `if (!hit)` branch's else, below), so a ⠿ that behaved
8581
+ // differently would be the surprise; neither §3.3 nor §4.5 gives a
8582
+ // modified ⠿ press a meaning of its own; and a modified press that never
8583
+ // crosses the threshold still opens the menu, so nothing was taken away.
8584
+ // Pinned by the RV5 scenario in test/editor-client-runtime.test.js.
8585
+ if (armBlockDrag(e)) return;
7166
8586
  if (e.shiftKey) { beginShiftClickSelection(e); return; }
7167
8587
  armBlockSelDrag(e);
7168
8588
  return;
@@ -7224,12 +8644,30 @@
7224
8644
  updateBlockSelDrag(e);
7225
8645
  });
7226
8646
 
8647
+ // S4 Task 2: the ⠿ block drag's own pointermove. Registered alongside the
8648
+ // other two rather than folded into either: the table drag's listener bails
8649
+ // on `!tePointer` (the state this one runs in) and the selection drag's bails
8650
+ // on `!blockSelDrag` (likewise). updateBlockDrag()'s own first lines are the
8651
+ // symmetric bail.
8652
+ document.addEventListener('pointermove', (e) => {
8653
+ updateBlockDrag(e);
8654
+ });
8655
+
7227
8656
  document.addEventListener('pointerup', async (e) => {
7228
8657
  // S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
7229
8658
  // this file, so the block-selection drag ends on the pointer events, the
7230
8659
  // same skeleton the table drag uses. Before the `!tePointer` bail: a
7231
8660
  // selection drag is armed precisely when no grip gesture is in flight.
7232
8661
  endBlockSelDrag();
8662
+ // S4 Task 2: and the ⠿ block drag's. Same placement reasoning as the line
8663
+ // above — a block drag is armed precisely when no grip gesture is in
8664
+ // flight, so it must be ended before the `!tePointer` bail.
8665
+ // S4 Task 3: AWAITED, now that the drop writes. endBlockDrag()'s own
8666
+ // teardown runs synchronously (an async function body runs to its first
8667
+ // await), so the `tePointer` branch below is not reordered around any
8668
+ // state it reads; what the await buys is that the commit and its
8669
+ // re-render are complete before this handler returns.
8670
+ await endBlockDrag();
7233
8671
  if (!tePointer) return;
7234
8672
  const st = tePointer;
7235
8673
  releaseTeCapture(st);
@@ -7275,6 +8713,9 @@
7275
8713
  // actually moved mid-drag, only the indicator line).
7276
8714
  document.addEventListener('pointercancel', () => {
7277
8715
  cancelTeDrag();
8716
+ // S4 Task 2: the same abort ends a ⠿ block drag. Nothing was mutated (only
8717
+ // the indicator moved), so this is the same unconditional cleanup.
8718
+ cancelBlockDrag();
7278
8719
  // S3 Task 4: the same abort ends a block-selection drag. The selection it
7279
8720
  // has built so far STANDS (§4.4: nothing but Escape and a plain click
7280
8721
  // clears) — what must not survive is the live drag, or the next stray
@@ -7291,6 +8732,9 @@
7291
8732
  // when no gesture is in flight, so this is safe to fire on every blur.
7292
8733
  window.addEventListener('blur', () => {
7293
8734
  cancelTeDrag();
8735
+ // S4 Task 2: same again for the ⠿ block drag — an undeliverable pointerup
8736
+ // is an undeliverable pointerup whichever gesture is holding it.
8737
+ cancelBlockDrag();
7294
8738
  // S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
7295
8739
  // drag in flight when focus left is exactly as undeliverable-pointerup as
7296
8740
  // a row drag's, so it is torn down here too.
@@ -7937,6 +9381,11 @@
7937
9381
  // closeGutterMenu() first, so gutterMenuBlockEl is null by then.
7938
9382
  if (e.key === 'Escape') {
7939
9383
  if (tePointer && tePointer.dragging) { e.preventDefault(); cancelTeDrag(); return; }
9384
+ // S4 Task 2: the ⠿ block drag gets the same first rung, and for the same
9385
+ // reason — Escape-during-a-drag is a distinct gesture from
9386
+ // Escape-reverts-the-burst, and the drag must be the one that answers.
9387
+ // Mutually exclusive with the rung above (one pointer, one gesture).
9388
+ if (blockDragState && blockDragState.dragging) { e.preventDefault(); cancelBlockDrag(); return; }
7940
9389
  if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
7941
9390
  if (gutterMenuBlockEl || insertMenuBlockEl) {
7942
9391
  e.preventDefault();
@@ -8312,6 +9761,20 @@
8312
9761
  hideTableGrips();
8313
9762
  return;
8314
9763
  }
9764
+ // S4 Task 2: and a third, in the same shape, for the ⠿ block drag —
9765
+ // without it this listener would keep repainting the + bubbles and
9766
+ // repositioning the grips onto whatever row the cursor is dragging
9767
+ // across, on top of the drop indicator, every frame of the gesture. Both
9768
+ // are HIDDEN rather than merely left un-recomputed, so anything already
9769
+ // showing from the frame before the threshold was crossed cannot linger
9770
+ // stale. Unlike the table gate above there is no "active grip" to spare:
9771
+ // the drag's own handle is a per-block button that the pointer has
9772
+ // already left, and the indicator is the whole of the feedback.
9773
+ if (blockDragState && blockDragState.dragging) {
9774
+ hideTableInsertBubbles();
9775
+ hideTableGrips();
9776
+ return;
9777
+ }
8315
9778
  updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
8316
9779
  updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
8317
9780
  });