@helping-ai-workflow/md2doc 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -434,9 +434,9 @@
434
434
  // reordered span it refused 1425 of the 4067 legal drops; the clamp now
435
435
  // answers 1411 of those, and what this still refuses is the 14 no
436
436
  // removal-clamp can reach — a batch whose last member is shallower than the
437
- // block it lands before, i.e. the INSERTION half of the move. See
438
- // performListItemDrop() for the single site and BLOCK_MOVE_ORPHAN_MESSAGE
439
- // for the family and the sweep behind those numbers.
437
+ // block it lands before, i.e. the INSERTION half of the move. v3.11: those
438
+ // 14 no longer refuse — performListItemDrop() hands them to
439
+ // moveSpanAcrossLists(), whose own clamp re-anchors the landing.
440
440
  function spanIndentsAreAnchored(items) {
441
441
  let prevLi = null;
442
442
  for (let i = 0; i < (items || []).length; i++) {
@@ -3445,10 +3445,37 @@
3445
3445
  return out;
3446
3446
  }
3447
3447
 
3448
+ // v3.11 (batch-2 §6): every list serialization goes through here, so every
3449
+ // caller agrees on where a LOOSE list's blank lines go — the commit, and the
3450
+ // line arithmetic (runLineOfBlock(), the per-li commit's lineMeta index)
3451
+ // that has to land on the same lines. Read off the FILE, never guessed from
3452
+ // the DOM: two items that were already neighbours keep whatever separated
3453
+ // them; a new neighbour (a deletion closed the gap, a move or a copy landed,
3454
+ // Enter made the item) takes the spacing of the list it joins, which the
3455
+ // renderer stamped from marked's own looseness as data-list-loose.
3456
+ function serializeListEls(els, opts) {
3457
+ return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore }, opts || {}));
3458
+ }
3459
+ function looseBlankBefore(el, prevEl) {
3460
+ if (!prevEl) return false;
3461
+ const rec = blockRecOf(el);
3462
+ const prevRec = blockRecOf(prevEl);
3463
+ if (rec && prevRec && rec !== prevRec && blocks[blocks.indexOf(rec) - 1] === prevRec) {
3464
+ for (let ln = prevRec.endLine + 1; ln < rec.startLine; ln++) {
3465
+ if (String(lines[ln - 1]).trim() === '') return true;
3466
+ }
3467
+ return false;
3468
+ }
3469
+ const loose = (x) => x.getAttribute('data-list-loose') === '1';
3470
+ // A brand-new item (no record yet) carries no list of its own to ask, so
3471
+ // the item after it answers for the list both of them are in.
3472
+ return prevRec ? loose(prevEl) : (loose(prevEl) || loose(el));
3473
+ }
3474
+
3448
3475
  function canWysiwygForLi(blockEl) {
3449
3476
  if (!blockEl) return false;
3450
3477
  if (blockOwnsNoLine(blockEl)) return false;
3451
- const res = listMd.serializeBlocks([blockEl]);
3478
+ const res = serializeListEls([blockEl]);
3452
3479
  return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
3453
3480
  }
3454
3481
 
@@ -5427,58 +5454,20 @@
5427
5454
 
5428
5455
  const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
5429
5456
  const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
5430
- const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
5431
5457
  const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
5432
- // 刪除's own answer to a mixed span: it accepts one whose lists are all
5433
- // covered whole (see listsCoveredWhole()), so its refusal names the fix.
5434
- const BATCH_PARTIAL_LIST_DELETE_MESSAGE = '刪除時清單只選到一部分:請把整個清單選進來,或只選清單項目';
5435
- // S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
5436
- //
5437
- // Task 3 raised all of these with a single provisional constant
5438
- // (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
5439
- // owned the final wording. They are three different problems with three
5440
- // OPPOSITE remedies, so one sentence for all of them sends two thirds of
5441
- // the users to fix the wrong end of their own gesture:
5442
- // * the SOURCE seam — the block cannot leave where it is, because the two
5443
- // lists it stands between would join into one loose run. No destination
5444
- // helps; the remedy is not to move it (or to move something else).
5445
- // * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
5446
- // is to aim somewhere else, which is the exact opposite advice.
5447
- // * OUT OF RUN — a list item may only move among its own §3.8 siblings.
5448
- // The remedy is to stay inside its own list.
5449
- // Naming follows the BATCH_*_MESSAGE convention above. None of the three is
5450
- // a substring of another, of §4.3's own gate message, or of
5451
- // BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
5452
- // refusal fired rather than that some banner mentioning 清單 appeared.
5453
- const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
5458
+ // The ⠿ drag's one remaining cross-boundary refusal: a drop onto a list
5459
+ // item that owns no source line (the outer item of a same-line nest such
5460
+ // as `- - b`) — there is no line to land in front of. v3.11 retired the
5461
+ // other four (source seam, destination seam inside a list, out of the run,
5462
+ // an orphaned landing): moveSpanAcrossLists() performs those moves instead.
5463
+ // Not a substring of any other refusal in this file, which is what lets a
5464
+ // test assert WHICH one fired.
5454
5465
  const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
5455
- const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
5456
- // S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
5457
- // of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
5458
- // removal half of the move through clampIndents()'s `{ removed: true }`),
5459
- // which answers every SOURCE-side orphan. What no removal-clamp can reach is
5460
- // the INSERTION half: a BATCH whose last member is SHALLOWER than the block
5461
- // it lands before leaves that block with no parent chain above it, at the
5462
- // NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
5463
- // indent array of length 2..6 and depth 0..3, every operand-set size and
5464
- // every destination the run gate admits: 4067 drops, 1425 refused by Task
5465
- // 4's predicate, 14 still unanchored after the clamp — every one of them a
5466
- // multi-block set breaking at the block immediately after the landed set.
5467
- // (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
5468
- //
5469
- // RE-WORDED with the narrowing, and the wording is the point: Task 4's
5470
- // 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
5471
- // was true while the capability was missing and is a lie now that it is not.
5472
- // What is left is a DESTINATION objection with the same remedy
5473
- // BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
5474
- // end of the gesture is at fault. Still not a substring of any other refusal
5475
- // in this file, which is what lets a test assert WHICH one fired.
5476
- const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
5477
5466
  // 轉換成's own refusals are NOT declared here. They are per block TYPE, and
5478
5467
  // they are the same ruling §3.7 applies to a single block's ⠿ — so they live
5479
5468
  // with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
5480
5469
  // toggleGutterMenu(), where the menu-hiding call site can share them. The
5481
- // four constants above are shape-level and belong to every batch operation.
5470
+ // constants above are shape-level and belong to every batch operation.
5482
5471
 
5483
5472
  function blockElById(id) {
5484
5473
  return document.querySelector('.ed-block[data-block-id="' + id + '"]');
@@ -5728,9 +5717,11 @@
5728
5717
  // null (dropped gesture, no source
5729
5718
  // line, empty/gapped/no-element
5730
5719
  // batch)
5731
- // 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
5732
- // 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
5733
- // 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE)
5720
+ // 2. (v3.11: retired — a mixed / withheld / two-list span routes to
5721
+ // convertSpanAcrossLists(), which has its own exits below)
5722
+ // 3. the all-withheld refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
5723
+ // 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE; now
5724
+ // unreachable, kept as a guard)
5734
5725
  // 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
5735
5726
  // 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
5736
5727
  // — six, not three, plus `result.op === null` (a commit that wrote nothing)
@@ -5776,41 +5767,20 @@
5776
5767
  const els = operands.els;
5777
5768
  const recs = operands.recs;
5778
5769
  const shape = spanListKinds(els);
5779
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
5780
- // §3.7 / §7 withhold 轉換成 from some block types ENTIRELY —
5781
- // toggleGutterMenu() hides the item outright, and the list plus every
5782
- // reason is in BATCH_CONVERT_WITHHELD_MESSAGES above it. This is the SAME
5783
- // predicate, asked of a SET: none of those reasons stops applying because
5784
- // the block happens to be one member of a span, and a batch whose grip is a
5785
- // paragraph reaches the withheld member anyway. MEASURED, before this gate
5786
- // existed: a table span wrote '> | A | B |' … / '- | A | B |' with its other
5787
- // rows as continuations, an hr span wrote '- ---', an html span wrote
5788
- // '> <div>x</div>'. Nothing is LOST in any of them (they all still lex as
5789
- // what they were) — which is exactly why it is so quiet: the single-block ⠿
5790
- // and the batch ⠿ give OPPOSITE answers to the same question with nothing
5791
- // on screen saying so, the shape §3.6's 2026-08-31 「不得另寫一條」 ruling
5792
- // exists to prevent. ⇒ REFUSE, with a banner (§3.6: 靜默不動作是缺陷), file
5793
- // byte-identical.
5794
- //
5795
- // The banner names the type that is ACTUALLY in the span, not one of the
5796
- // four shape-level messages above: BATCH_MIXED names list items vs other
5797
- // blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
5798
- // names non-contiguity — either would name something the user's selection
5799
- // has not done. So would telling a user with an hr in the selection that it
5800
- // contains a table.
5801
- //
5802
- // SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
5803
- // holding any of these types are measured correct and have no equivalent
5804
- // objection — they do not need a target that can carry cells, or a marker
5805
- // to strip — so the predicate deliberately does NOT reach
5806
- // resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
5807
- // those five cells as APPLYING so a widening shows up as a failure too.
5808
- // Placed BELOW the mixed gate so no span that already refuses changes which
5809
- // banner it gets.
5810
- const withheld = shape.kinds.filter(convertWithheldFor)[0];
5811
- if (withheld) {
5812
- refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
5813
- return;
5770
+ // §3.7 / §7 withhold 轉換成 from a table, an hr and a raw html block —
5771
+ // toggleGutterMenu() hides the item outright on a single one of them.
5772
+ // v3.11: a SPAN holding one converts its other members and keeps the
5773
+ // withheld ones byte for byte, with a notice naming what was skipped
5774
+ // (Notion skips what it cannot turn); a span mixing list items with other
5775
+ // blocks, or taking items of two lists, converts as well. All three used
5776
+ // to refuse (BATCH_MIXED_MESSAGE / BATCH_MULTIRUN_MESSAGE /
5777
+ // BATCH_CONVERT_WITHHELD_MESSAGES). Before the withheld refusal existed a
5778
+ // table span wrote '> | A | B |' … — convertSpanAcrossLists() never
5779
+ // strips a withheld member, which is why it is the route for all of them.
5780
+ // A span of ONLY withheld blocks still refuses: nothing in it converts.
5781
+ if ((shape.anyLi && !shape.allLi) || shape.kinds.some(convertWithheldFor) ||
5782
+ (shape.allLi && !batchRunOf(els))) {
5783
+ return await convertSpanAcrossLists(els, recs, shape.kinds, target);
5814
5784
  }
5815
5785
 
5816
5786
  // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
@@ -5819,25 +5789,20 @@
5819
5789
  // is exactly what listRunOf() returns (the outermost run PLUS every
5820
5790
  // descendant of its members) — see deleteListItemsViaGutter()'s own note.
5821
5791
  //
5822
- // ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
5823
- // refusals below and, further down, of stripMarker(): a multi-line li must
5824
- // report §4.1's 「此清單含不支援的格式,無法調整結構」 and not
5825
- // convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
5826
- // would get if stripMarker() saw it first (that function refuses a
5827
- // multi-line li too, for its own, narrower reason). The runtime scenario
5828
- // 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
5829
- // is what notices if this order is ever flipped.
5792
+ // It sits AHEAD of the per-target routing below, so a run the gate
5793
+ // refuses (an unsupported shape) reports §4.1's
5794
+ // 「此清單含不支援的格式,無法調整結構」 before anything else is tried.
5830
5795
  //
5831
- // A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's own
5832
- // text or line count, so a multi-line li refuses as a TARGET while
5833
- // remaining a perfectly good bystander. S3 Task 6 passes the WHOLE operand
5834
- // set as the target list — every member is being rewritten, so every member
5835
- // has to clear the gate, not just the one the ⠿ was pressed on.
5796
+ // v3.11: { replaysTargets: true } — a multi-line li converts (a li→li
5797
+ // change replays its bytes with the marker's column delta; li → non-list
5798
+ // reads them from `lines` through stripMarker()), so §4.1 修訂 2's
5799
+ // TARGET refusal no longer applies. S3 Task 6 passes the WHOLE operand set
5800
+ // — every member is being rewritten, so every member clears the gate.
5836
5801
  let liRun = null;
5837
5802
  if (shape.allLi) {
5838
5803
  liRun = batchRunOf(els);
5839
5804
  if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
5840
- if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
5805
+ if (!listRunSupportsStructuralEdit(liRun, els, { replaysTargets: true })) { refuseStructuralListEdit(); return; }
5841
5806
  }
5842
5807
 
5843
5808
  // S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
@@ -6013,7 +5978,12 @@
6013
5978
  const els = operands.els;
6014
5979
  const recs = operands.recs;
6015
5980
  const shape = spanListKinds(els);
6016
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
5981
+ // v3.11: a span mixing list items with other blocks, or taking items of
5982
+ // two lists, duplicates too (it used to refuse with BATCH_MIXED_MESSAGE /
5983
+ // BATCH_MULTIRUN_MESSAGE).
5984
+ if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
5985
+ return (await duplicateSpanAcrossLists(els, recs)) ? recs[0].startLine : undefined;
5986
+ }
6017
5987
 
6018
5988
  if (shape.allLi) {
6019
5989
  // recs[0].startLine is unchanged by this commit — duplicateListItems()'s
@@ -6068,6 +6038,60 @@
6068
6038
  // of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
6069
6039
  // a refusal (nothing was duplicated, the block is still where it was) apart
6070
6040
  // from a completed duplicate.
6041
+ // v3.11: 建立副本 over a span that mixes list items with other blocks, or
6042
+ // takes items of two lists. The copy is the span's own source lines,
6043
+ // byte for byte, inserted after the span (after the last member's subtree
6044
+ // when that member is a list item, like duplicateListItems()), in ONE
6045
+ // commit. Where the copy meets a neighbour it joins tight when both sides
6046
+ // are list items — the copied items rejoin the list, as in Notion — and is
6047
+ // kept apart by a blank line otherwise.
6048
+ async function duplicateSpanAcrossLists(els, recs) {
6049
+ const first = recs[0];
6050
+ const lastEl = els[els.length - 1];
6051
+ let endRec = recs[recs.length - 1];
6052
+ if (lastEl.getAttribute('data-block-type') === 'li') {
6053
+ const sub = subtreeBlocksAfter(lastEl, Number(lastEl.getAttribute('data-indent')) || 0);
6054
+ const subRec = sub.length ? blockRecOf(sub[sub.length - 1]) : null;
6055
+ if (subRec) endRec = subRec;
6056
+ }
6057
+ const at = endRec.endLine;
6058
+ const copy = lines.slice(first.startLine - 1, recs[recs.length - 1].endLine);
6059
+ const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
6060
+ const endEl = blockElById(endRec.id);
6061
+ const nextRec = blocks[blocks.indexOf(endRec) + 1] || null;
6062
+ const nextEl = nextRec ? blockElById(nextRec.id) : null;
6063
+ const nextLine = at < lines.length ? String(lines[at]) : null;
6064
+ const out = [];
6065
+ const tightBefore = isLi(endEl) && isLi(els[0]);
6066
+ if (!tightBefore) {
6067
+ // A blank line in front: an indented first line would read as code, and
6068
+ // an ordered item restarts its own count.
6069
+ if (/^( {4}|\t)/.test(copy[0])) { refuseStructuralListEdit(); return false; }
6070
+ if (isLi(els[0])) copy[0] = copy[0].replace(/^(\s*)\d+([.)])/, '$11$2');
6071
+ out.push('');
6072
+ }
6073
+ out.push(...copy);
6074
+ const tightAfter = isLi(lastEl) && isLi(nextEl) && nextLine !== null && nextLine.trim() !== '';
6075
+ if (!tightAfter && nextLine !== null && nextLine.trim() !== '') out.push('');
6076
+ const result = commitRangeEdit({ lines, blocks, stack }, at, at,
6077
+ [lines[at - 1]].concat(out).join('\n'));
6078
+ if (result.op === null) return false;
6079
+ endBurstWithoutResolve();
6080
+ const prevLines = lines;
6081
+ lines = result.lines;
6082
+ declareCollapse({ startLine: first.startLine, endLine: recs[recs.length - 1].endLine });
6083
+ const editRange = result.op
6084
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6085
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6086
+ delta: result.op.after.length - result.op.before.length }
6087
+ : undefined;
6088
+ if (!(await safeRerenderAll(editRange))) {
6089
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
6090
+ return false;
6091
+ }
6092
+ return true;
6093
+ }
6094
+
6071
6095
  async function duplicateListItems(liEls, recs) {
6072
6096
  // Every list batch is one run's problem — see batchRunOf().
6073
6097
  const run = batchRunOf(liEls);
@@ -6079,7 +6103,7 @@
6079
6103
  // it adds the item's lines over again), so a multi-line li refuses as a
6080
6104
  // TARGET while remaining a perfectly good bystander — and in a batch EVERY
6081
6105
  // member is a target.
6082
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
6106
+ if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
6083
6107
  // Captured BEFORE the copy enters the span. The copy carries the
6084
6108
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
6085
6109
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
@@ -6262,15 +6286,13 @@
6262
6286
  const els = operands.els;
6263
6287
  const recs = operands.recs;
6264
6288
  const shape = spanListKinds(els);
6265
- // A mixed span is deletable exactly when it covers every list it touches
6266
- // WHOLE: nothing of those lists survives, so there is no run to renumber
6267
- // or clamp, and the span is one contiguous line range like any non-list
6268
- // span -- the commitRangeRemoval() branch below, one undo op. A list
6269
- // covered only in PART would need its survivors re-serialized in the same
6270
- // commit as the non-list removal, which is still refused.
6271
- if (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) {
6272
- refuseStructuralListEdit(BATCH_PARTIAL_LIST_DELETE_MESSAGE); return;
6273
- }
6289
+ // A mixed span that covers every list it touches WHOLE is one contiguous
6290
+ // line range like any non-list span -- the commitRangeRemoval() branch
6291
+ // below, one undo op. v3.11: a list covered only in PART (or items of two
6292
+ // lists) no longer refuses — deleteSpanAcrossLists(), routed below once
6293
+ // the caret landing has been read.
6294
+ const acrossLists = (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) ||
6295
+ (shape.allLi && !batchRunOf(els));
6274
6296
  // Fix round 1, item 2: the block immediately ABOVE the span, read out of
6275
6297
  // the PRE-commit `blocks` and carried to the wrapper. It cannot be
6276
6298
  // recovered afterwards from the hole's coordinate — see
@@ -6302,6 +6324,10 @@
6302
6324
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
6303
6325
  // below corrupts a list three separate ways — see
6304
6326
  // deleteListItemsViaGutter() for the measurements and the routing.
6327
+ if (acrossLists) {
6328
+ return (await deleteSpanAcrossLists(els, recs))
6329
+ ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
6330
+ }
6305
6331
  if (shape.allLi) {
6306
6332
  return (await deleteListItemsViaGutter(els))
6307
6333
  ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
@@ -6371,6 +6397,94 @@
6371
6397
  // of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
6372
6398
  // a refusal (nothing was removed, the block is still where it was) apart from
6373
6399
  // a completed delete before it goes looking for a neighbour.
6400
+ // v3.11: 刪除 over a span that cuts into one or more lists. ONE commit (one
6401
+ // undo step) over the region from the first touched list's first line to
6402
+ // the last touched list's last line: every touched list keeps its unselected
6403
+ // items (re-serialized the way deleteListItemsViaGutter() does it, bystander
6404
+ // bytes replayed), every other selected block is dropped. Two lists whose
6405
+ // survivors end up adjacent join tight — Notion's behaviour; the serializer
6406
+ // renumbers every ordered piece from 1, so the join is always well-formed.
6407
+ async function deleteSpanAcrossLists(els, recs) {
6408
+ const groups = [];
6409
+ for (let i = 0; i < els.length; i++) {
6410
+ const el = els[i];
6411
+ if (el.getAttribute('data-block-type') !== 'li') continue;
6412
+ let g = groups.find((x) => x.run.indexOf(el) !== -1);
6413
+ if (!g) {
6414
+ const run = listRunOf(el);
6415
+ if (!run.length) { refuseStructuralListEdit(); return false; }
6416
+ g = { run: run, targets: [] };
6417
+ groups.push(g);
6418
+ }
6419
+ g.targets.push(el);
6420
+ }
6421
+ for (let i = 0; i < groups.length; i++) {
6422
+ const g = groups[i];
6423
+ if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
6424
+ refuseStructuralListEdit(); return false;
6425
+ }
6426
+ g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
6427
+ if (!g.range) return false;
6428
+ }
6429
+ let lo = recs[0].startLine;
6430
+ let hi = recs[recs.length - 1].endLine;
6431
+ groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
6432
+ // Everything inside [lo, hi] must be either selected or one of a touched
6433
+ // list's own lines — anything else would be silently lost, so refuse.
6434
+ const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
6435
+ for (let i = 0; i < blocks.length; i++) {
6436
+ const b = blocks[i];
6437
+ if (b.endLine < lo || b.startLine > hi) continue;
6438
+ const k = recs.indexOf(b);
6439
+ if (k !== -1 && els[k].getAttribute('data-block-type') !== 'li' && inRun(b)) {
6440
+ refuseStructuralListEdit(); return false;
6441
+ }
6442
+ if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return false; }
6443
+ }
6444
+ groups.forEach((g) => {
6445
+ g.survivors = g.run.filter((el) => g.targets.indexOf(el) === -1);
6446
+ });
6447
+ mutateListRun(() => {
6448
+ groups.forEach((g) => {
6449
+ applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { removed: true });
6450
+ g.targets.forEach((el) => removeListItem(el));
6451
+ });
6452
+ });
6453
+ const carry = bystanderCarryOver([].concat(...groups.map((g) => g.survivors)));
6454
+ const pieces = groups
6455
+ .map((g) => g.survivors.length ? serializeListEls(g.survivors, { carryOver: carry }).md : '')
6456
+ .filter((piece) => piece !== '');
6457
+ let md = pieces.join('\n');
6458
+ if (md !== '') {
6459
+ // A removed block at an edge of the region: keep the new text apart
6460
+ // from a neighbour that is not a blank line.
6461
+ const head = groups[0];
6462
+ const tail = groups[groups.length - 1];
6463
+ const startsInRun = head && head.range.startLine === lo && head.survivors.length;
6464
+ const endsInRun = tail && tail.range.endLine === hi && tail.survivors.length;
6465
+ if (!startsInRun && lo > 1 && String(lines[lo - 2]).trim() !== '') md = '\n' + md;
6466
+ if (!endsInRun && hi < lines.length && String(lines[hi]).trim() !== '') md = md + '\n';
6467
+ }
6468
+ declareCollapse(null);
6469
+ const result = md === ''
6470
+ ? commitRangeRemoval({ lines, blocks, stack }, lo, hi)
6471
+ : commitRangeEdit({ lines, blocks, stack }, lo, hi, md);
6472
+ endBurstWithoutResolve();
6473
+ const prevLines = lines;
6474
+ if (result.op !== null) lines = result.lines;
6475
+ const editRange = result.op
6476
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6477
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6478
+ delta: result.op.after.length - result.op.before.length }
6479
+ : undefined;
6480
+ const ok = await safeRerenderAll(editRange);
6481
+ if (!ok) {
6482
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
6483
+ return false;
6484
+ }
6485
+ return true;
6486
+ }
6487
+
6374
6488
  async function deleteListItemsViaGutter(liEls) {
6375
6489
  // Every list batch is one run's problem — see batchRunOf().
6376
6490
  const run = batchRunOf(liEls);
@@ -6381,7 +6495,7 @@
6381
6495
  // re-indent are covered, not just the target's own. Deleting is NOT
6382
6496
  // column-only: it removes the target's lines outright, so a multi-line
6383
6497
  // target refuses per §4.1 — and in a batch every member is a target.
6384
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
6498
+ if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
6385
6499
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
6386
6500
  if (!range) return false;
6387
6501
  // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
@@ -6557,6 +6671,162 @@
6557
6671
  };
6558
6672
  }
6559
6673
 
6674
+ // v3.11: 轉換成 over a span the single-kind paths cannot take — list items
6675
+ // mixed with other blocks, items of two lists, or a table / hr / html block
6676
+ // among the members (those three have no content to re-host, so they are
6677
+ // kept as they are and a notice says so — Notion skips what it cannot
6678
+ // turn). ONE commit over the region from the first touched list's first
6679
+ // line to the last touched list's last line: unselected items before the
6680
+ // span (first list) and after it (last list) are re-serialized the way
6681
+ // convertListItemsAway() does it, orphaned children clamped; every selected
6682
+ // block is stripMarker() → emitAs()'d from its own source lines. A list
6683
+ // target turns the members into one list (nested items keep their nesting,
6684
+ // re-based on the first member); pieces that are list items on both sides
6685
+ // of a seam join tight.
6686
+ async function convertSpanAcrossLists(els, recs, kinds, target) {
6687
+ const toList = convertMd.targetIsList(target);
6688
+ const isLi = (el) => el.getAttribute('data-block-type') === 'li';
6689
+ const groups = [];
6690
+ for (let i = 0; i < els.length; i++) {
6691
+ if (!isLi(els[i])) continue;
6692
+ let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
6693
+ if (!g) {
6694
+ const run = listRunOf(els[i]);
6695
+ if (!run.length) { refuseStructuralListEdit(); return; }
6696
+ g = { run: run, targets: [] };
6697
+ groups.push(g);
6698
+ }
6699
+ g.targets.push(els[i]);
6700
+ }
6701
+ for (let i = 0; i < groups.length; i++) {
6702
+ const g = groups[i];
6703
+ if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
6704
+ refuseStructuralListEdit(); return;
6705
+ }
6706
+ g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
6707
+ if (!g.range) return;
6708
+ }
6709
+ let lo = recs[0].startLine;
6710
+ let hi = recs[recs.length - 1].endLine;
6711
+ groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
6712
+ const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
6713
+ for (let i = 0; i < blocks.length; i++) {
6714
+ const b = blocks[i];
6715
+ if (b.endLine < lo || b.startLine > hi) continue;
6716
+ const k = recs.indexOf(b);
6717
+ if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
6718
+ if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
6719
+ }
6720
+ // Unselected items may only sit BEFORE the span (in its first list) or
6721
+ // AFTER it (in its last list) — the span is one contiguous line range.
6722
+ const firstEl = els[0];
6723
+ const lastEl = els[els.length - 1];
6724
+ const allEls = allBlockEls();
6725
+ const before = [];
6726
+ const after = [];
6727
+ for (let i = 0; i < groups.length; i++) {
6728
+ const g = groups[i];
6729
+ for (let j = 0; j < g.run.length; j++) {
6730
+ const el = g.run[j];
6731
+ if (g.targets.indexOf(el) !== -1) continue;
6732
+ if (allEls.indexOf(el) < allEls.indexOf(firstEl)) before.push(el);
6733
+ else if (allEls.indexOf(el) > allEls.indexOf(lastEl)) after.push(el);
6734
+ else { refuseStructuralListEdit(); return; }
6735
+ }
6736
+ }
6737
+
6738
+ // The members, converted. A run of consecutive convertible members is one
6739
+ // chunk; a withheld member is its own chunk, kept byte for byte.
6740
+ const skipped = [];
6741
+ const chunks = [];
6742
+ let cur = null;
6743
+ let level0 = null;
6744
+ const ordinals = [];
6745
+ for (let i = 0; i < els.length; i++) {
6746
+ const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
6747
+ if (convertWithheldFor(kinds[i])) {
6748
+ if (skipped.indexOf(kinds[i]) === -1) skipped.push(kinds[i]);
6749
+ chunks.push({ list: false, text: src.join('\n') });
6750
+ cur = null;
6751
+ ordinals.length = 0;
6752
+ continue;
6753
+ }
6754
+ const stripped = convertMd.stripMarker(src, kinds[i]);
6755
+ if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
6756
+ if (!toList) {
6757
+ chunks.push({ list: false, text: convertMd.emitAs(stripped.content, target, {}).join('\n') });
6758
+ continue;
6759
+ }
6760
+ // Nesting, re-based so the first member sits at level 0 and no item
6761
+ // is ever more than one level deeper than the one above it.
6762
+ let level = isLi(els[i]) ? (Number(els[i].getAttribute('data-indent')) || 0) : 0;
6763
+ if (level0 === null) level0 = level;
6764
+ level = Math.max(0, level - level0);
6765
+ if (!cur) { cur = { list: true, lines: [], prevLevel: -1 }; chunks.push(cur); ordinals.length = 0; }
6766
+ level = Math.min(level, cur.prevLevel + 1);
6767
+ cur.prevLevel = level;
6768
+ ordinals.length = level + 1;
6769
+ ordinals[level] = (ordinals[level] || 0) + 1;
6770
+ const width = target === 'ol' ? 3 : 2;
6771
+ const checked = /^\s*(?:[-*+]|\d+[.)])\s+\[[xX]\]/.test(src[0]);
6772
+ cur.lines.push(...convertMd.emitAs(stripped.content, target,
6773
+ { ordinal: ordinals[level], checked: checked, indentPrefix: ' '.repeat(level * width) }));
6774
+ }
6775
+ const middle = chunks.map((c) => (c.list ? { list: true, text: c.lines.join('\n') } : c));
6776
+ if (skipped.length === middle.length) {
6777
+ refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[skipped[0]]); return;
6778
+ }
6779
+
6780
+ // Orphans after the span lose an ancestor that is no longer an item.
6781
+ if (!toList && after.length && groups.length) {
6782
+ const g = groups[groups.length - 1];
6783
+ mutateListRun(() => {
6784
+ applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { operatedBecomes: { type: convertedBlockType(target) } });
6785
+ });
6786
+ }
6787
+ const carry = bystanderCarryOver(before.concat(after));
6788
+ const pieces = [];
6789
+ if (before.length) pieces.push({ list: true, text: serializeListEls(before, { carryOver: carry }).md });
6790
+ const midFrom = pieces.length;
6791
+ pieces.push(...middle);
6792
+ const midTo = pieces.length;
6793
+ if (after.length) pieces.push({ list: true, text: serializeListEls(after, { carryOver: carry }).md });
6794
+ const out = [];
6795
+ let midStart = null;
6796
+ let midEnd = null;
6797
+ pieces.forEach((p, i) => {
6798
+ if (i > 0) {
6799
+ if (!(p.list && pieces[i - 1].list)) out.push('');
6800
+ }
6801
+ if (i === midFrom) midStart = out.length;
6802
+ out.push(...p.text.split('\n'));
6803
+ if (i === midTo - 1) midEnd = out.length - 1;
6804
+ });
6805
+ let lead = 0;
6806
+ if (!before.length && lo > 1 && String(lines[lo - 2]).trim() !== '') { out.unshift(''); lead = 1; }
6807
+ if (!after.length && hi < lines.length && String(lines[hi]).trim() !== '') out.push('');
6808
+
6809
+ const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
6810
+ if (result.op === null) return;
6811
+ endBurstWithoutResolve();
6812
+ const prevLines = lines;
6813
+ lines = result.lines;
6814
+ declareCollapse({ startLine: lo + lead + midStart, endLine: lo + lead + midEnd });
6815
+ const editRange = result.op
6816
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6817
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6818
+ delta: result.op.after.length - result.op.before.length }
6819
+ : undefined;
6820
+ if (!(await safeRerenderAll(editRange))) {
6821
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
6822
+ return;
6823
+ }
6824
+ if (skipped.length) {
6825
+ showNotice('已略過無法轉換的區塊:' + skipped.map((k) => CONVERT_SKIP_LABELS[k]).join('、'));
6826
+ }
6827
+ }
6828
+ const CONVERT_SKIP_LABELS = { table: '表格', hr: '分隔線', html: 'HTML 區塊' };
6829
+
6560
6830
  async function convertListItemsType(liEls, run, recs, target) {
6561
6831
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
6562
6832
  if (!range) return;
@@ -6738,13 +7008,13 @@
6738
7008
  // back through escapeText() — see convertListItemsType()'s note.
6739
7009
  const carry = bystanderCarryOver(before.concat(after));
6740
7010
  const pieces = [];
6741
- if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
7011
+ if (before.length) pieces.push(serializeListEls(before, { carryOver: carry }).md);
6742
7012
  // Which piece the converted blocks are — the collapse range below counts
6743
7013
  // emitted LINES up to it, and "0 or 1" is only true while `before` is the
6744
7014
  // one optional piece ahead of it.
6745
7015
  const convertedPieceIdx = pieces.length;
6746
7016
  pieces.push(convertedMd);
6747
- if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
7017
+ if (after.length) pieces.push(serializeListEls(after, { carryOver: carry }).md);
6748
7018
  let md = pieces.join('\n\n');
6749
7019
 
6750
7020
  // §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
@@ -7146,7 +7416,7 @@
7146
7416
  // source range.
7147
7417
  const carry = bystanderCarryOver(runEls, editedLiEl);
7148
7418
  const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
7149
- listMd.serializeBlocks(runEls, { carryOver: carry });
7419
+ serializeListEls(runEls, { carryOver: carry });
7150
7420
  // Refuse if the EDITED li itself has unsupported inline content.
7151
7421
  // Show banner + teardown + rerenderAll (file is untouched, burst never
7152
7422
  // wrote to `lines`) + return false — this path does NOT fall back to
@@ -7172,7 +7442,15 @@
7172
7442
  const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
7173
7443
  if (!range) { endBurstWithoutResolve(); return true; }
7174
7444
  let commitMd, commitStart, commitEnd;
7175
- if (unsupported.length > 0) {
7445
+ // v3.11: a LOOSE run used to reach this branch through its 'P' items,
7446
+ // and a loose run holding a same-line nest ('- - b', an item with no
7447
+ // line of its own) still has to: re-serializing the whole run re-emits
7448
+ // that nest in its canonical multi-line form, which inside a loose list
7449
+ // changes the rendered nesting (measured: '- - - b' came back with an
7450
+ // escaped empty item). Committing only the edited item's lines keeps it.
7451
+ const looseWithNest = runEls.some((el) => blockOwnsNoLine(el)) &&
7452
+ runEls.some((el) => el.getAttribute('data-list-loose') === '1');
7453
+ if (unsupported.length > 0 || looseWithNest) {
7176
7454
  // The run contains SOME unsupported content — commit only the edited
7177
7455
  // li's own line range so nothing else in the run is round-tripped
7178
7456
  // through the tight (blank-line-collapsed) runMd.
@@ -8006,7 +8284,7 @@
8006
8284
  async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
8007
8285
  const span = runEls || [];
8008
8286
  const presetRange = opts && opts.presetRange;
8009
- const { md } = listMd.serializeBlocks(span, { carryOver: (opts && opts.carryOver) || null });
8287
+ const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8010
8288
  // The run's line range is read back off its own li blocks' ids — which
8011
8289
  // requires at least one li to still BE there. A caller whose mutation
8012
8290
  // removed the run's last item therefore captures the range BEFORE mutating
@@ -8073,7 +8351,7 @@
8073
8351
  // can emit a different number of lines than a re-serialized one would (a
8074
8352
  // blank continuation is dropped on the re-serialize path but replayed
8075
8353
  // verbatim here), and this answer is an index INTO those lines.
8076
- const { lineMeta } = listMd.serializeBlocks(runEls, { carryOver: carryOver || null });
8354
+ const { lineMeta } = serializeListEls(runEls, { carryOver: carryOver || null });
8077
8355
  // A provisional block (split's new item) has no id yet, so it cannot be
8078
8356
  // found by one — fall back to counting the lines emitted before it.
8079
8357
  if (targetId === null) {
@@ -8082,7 +8360,16 @@
8082
8360
  const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
8083
8361
  let n = 0;
8084
8362
  lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
8085
- return range.startLine + n;
8363
+ // v3.11: a loose list's blank lines (lineMeta `blank`) sit between the
8364
+ // items counted above and this one; they are lines too.
8365
+ let k = 0;
8366
+ let seen = 0;
8367
+ while (k < lineMeta.length && seen < n) {
8368
+ if (before.indexOf(lineMeta[k].blockId) !== -1) seen++;
8369
+ k++;
8370
+ }
8371
+ while (k < lineMeta.length && lineMeta[k].blank) k++;
8372
+ return range.startLine + k;
8086
8373
  }
8087
8374
  const idx = lineMeta.findIndex((m) => m.blockId === targetId);
8088
8375
  if (idx === -1) return null;
@@ -8169,13 +8456,21 @@
8169
8456
  // excluding a burst whose surface has actually been edited.
8170
8457
  function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
8171
8458
  if (!runEls || !runEls.length) return false;
8172
- const res = listMd.serializeBlocks(runEls);
8459
+ const res = serializeListEls(runEls);
8173
8460
  const multi = res.multiLineBlockIds || [];
8174
8461
  // Anything OTHER than MULTILINE still refuses run-wide, unchanged.
8175
8462
  for (let i = 0; i < res.unsupported.length; i++) {
8176
8463
  if (res.unsupported[i] !== 'MULTILINE') return false;
8177
8464
  }
8178
8465
  if (opts && opts.columnOnly) return true;
8466
+ // v3.11: `replaysTargets` — the operation never re-serializes a target's
8467
+ // CONTENT: it removes the target (delete), replays its source bytes
8468
+ // (duplicate's copy carries the original's id into bystanderCarryOver();
8469
+ // a li→li type change replays them with the marker's column delta), or
8470
+ // reads them straight from `lines` (li → non-list, convert-md.js's
8471
+ // stripMarker()). A multi-line target is then no different from a
8472
+ // multi-line bystander, which has always been fine.
8473
+ if (opts && opts.replaysTargets) return true;
8179
8474
  // S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
8180
8475
  // one run and every one of them has to clear §4.1's multi-line gate, not
8181
8476
  // just the one the ⠿ was pressed on. An empty array is the same question as
@@ -9122,6 +9417,114 @@
9122
9417
  // what makes 「段落 no-op」 a real no-op instead of a re-serialization that
9123
9418
  // could move bytes nobody asked it to, and it is why this is ONE
9124
9419
  // commitRangeEdit and therefore one undo op.
9420
+ // v3.11: Tab / Shift+Tab over a span mixing list items with other blocks,
9421
+ // or taking items of two lists. Each kind follows its own rule — items
9422
+ // indent (indentListItemsBySelection()'s headroom and clamp, per list),
9423
+ // headings change depth (changeHeadingDepthsInSpan()), paragraphs stay —
9424
+ // in ONE commit over the region the touched lists span, which is what the
9425
+ // old refusal said could not be done (two commits, two undo ops). Lists
9426
+ // that are adjacent blocks (a marker change between them, no gap) move as
9427
+ // one group, so an item of the second can land under an item of the first.
9428
+ async function tabSpanAcrossLists(els, recs, dir) {
9429
+ const isLi = (el) => el.getAttribute('data-block-type') === 'li';
9430
+ const all = allBlockEls();
9431
+ const groups = [];
9432
+ for (let i = 0; i < els.length; i++) {
9433
+ if (!isLi(els[i])) continue;
9434
+ let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
9435
+ if (!g) {
9436
+ const run = listRunOf(els[i]);
9437
+ if (!run.length) { refuseStructuralListEdit(); return; }
9438
+ const prev = groups[groups.length - 1];
9439
+ if (prev && all.indexOf(run[0]) === all.indexOf(prev.run[prev.run.length - 1]) + 1) {
9440
+ prev.run = prev.run.concat(run);
9441
+ g = prev;
9442
+ } else {
9443
+ g = { run: run, targets: [] };
9444
+ groups.push(g);
9445
+ }
9446
+ }
9447
+ g.targets.push(els[i]);
9448
+ }
9449
+ for (let i = 0; i < groups.length; i++) {
9450
+ const g = groups[i];
9451
+ if (!listRunSupportsStructuralEdit(g.run, g.targets, { columnOnly: true })) {
9452
+ refuseStructuralListEdit(); return;
9453
+ }
9454
+ g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
9455
+ if (!g.range) return;
9456
+ }
9457
+ let lo = recs[0].startLine;
9458
+ let hi = recs[recs.length - 1].endLine;
9459
+ groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
9460
+ const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
9461
+ for (let i = 0; i < blocks.length; i++) {
9462
+ const b = blocks[i];
9463
+ if (b.endLine < lo || b.startLine > hi) continue;
9464
+ const k = recs.indexOf(b);
9465
+ if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
9466
+ if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
9467
+ }
9468
+
9469
+ let changed = false;
9470
+ groups.forEach((g) => {
9471
+ g.delta = batchIndentDelta(g.targets, dir);
9472
+ if (g.delta !== 0) changed = true;
9473
+ });
9474
+ // Segments in document order: each list group re-serialized, each other
9475
+ // member's own lines (a heading re-depthed). Whatever lies between two
9476
+ // segments — blank lines, by the check above — is copied as it is.
9477
+ const segs = [];
9478
+ for (let i = 0; i < recs.length; i++) {
9479
+ if (isLi(els[i])) continue;
9480
+ const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
9481
+ if (recs[i].type === 'heading' && /^#{1,6}(\s|$)/.test(src[0])) {
9482
+ const depth = headingDepthOf(src[0]);
9483
+ const next = Math.max(1, Math.min(6, depth + dir));
9484
+ if (next !== depth) { src[0] = withHeadingDepth(src[0], next); changed = true; }
9485
+ }
9486
+ segs.push({ start: recs[i].startLine, end: recs[i].endLine, text: src });
9487
+ }
9488
+ if (!changed) return;
9489
+ mutateListRun(() => {
9490
+ groups.forEach((g) => {
9491
+ if (g.delta === 0) return;
9492
+ const oldIndent = spanMinIndent(g.targets);
9493
+ g.targets.forEach((el) => setBlockIndent(el,
9494
+ Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + g.delta)));
9495
+ applyIndentClamp(g.run, g.targets, oldIndent);
9496
+ });
9497
+ });
9498
+ const carry = bystanderCarryOver([].concat(...groups.map((g) => g.run)), null);
9499
+ groups.forEach((g) => {
9500
+ segs.push({ start: g.range.startLine, end: g.range.endLine,
9501
+ text: serializeListEls(g.run, { carryOver: carry }).md.split('\n') });
9502
+ });
9503
+ segs.sort((a, b) => a.start - b.start);
9504
+ const out = [];
9505
+ let at = lo;
9506
+ segs.forEach((sg) => {
9507
+ out.push(...lines.slice(at - 1, sg.start - 1));
9508
+ out.push(...sg.text);
9509
+ at = sg.end + 1;
9510
+ });
9511
+ out.push(...lines.slice(at - 1, hi));
9512
+ const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
9513
+ if (result.op === null) return;
9514
+ endBurstWithoutResolve();
9515
+ const prevLines = lines;
9516
+ lines = result.lines;
9517
+ declareCollapse({ startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine });
9518
+ const editRange = result.op
9519
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
9520
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
9521
+ delta: result.op.after.length - result.op.before.length }
9522
+ : undefined;
9523
+ if (!(await safeRerenderAll(editRange))) {
9524
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
9525
+ }
9526
+ }
9527
+
9125
9528
  async function changeHeadingDepthsInSpan(els, recs, dir) {
9126
9529
  const first = recs[0];
9127
9530
  const last = recs[recs.length - 1];
@@ -9179,13 +9582,14 @@
9179
9582
  const els = operands.els;
9180
9583
  const recs = operands.recs;
9181
9584
  const shape = spanListKinds(els);
9182
- // §3.6's 2026-08-31 ruling, inherited rather than re-invented. NOTE: this
9183
- // CONTRADICTS the S3 plan's Task 7 text ("a batch containing both kinds
9184
- // applies each rule to its own kind") — see the Task 7 carry. A mixed span
9185
- // would need the run's survivors re-serialized at the same time as lines
9186
- // outside the run are rewritten, i.e. two commits and therefore two undo
9187
- // ops, which §3.4's 「一次使用者手勢 = 恰好一個 undo op」 forbids.
9188
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
9585
+ // v3.11: a span mixing both kinds applies each rule to its own kind (the
9586
+ // S3 plan's Task 7 text) in ONE commit — tabSpanAcrossLists() — so §3.4's
9587
+ // 「一次使用者手勢 = 恰好一個 undo op」 holds; so does a span taking items of
9588
+ // two lists. Both used to refuse (§3.6's 2026-08-31 ruling, which only
9589
+ // stood because the combined commit did not exist).
9590
+ if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
9591
+ await tabSpanAcrossLists(els, recs, dir); return;
9592
+ }
9189
9593
  if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
9190
9594
  await changeHeadingDepthsInSpan(els, recs, dir);
9191
9595
  }
@@ -11649,7 +12053,7 @@
11649
12053
  if (!liveTableEl) return;
11650
12054
  const headerRow = headerRowOf(liveTableEl);
11651
12055
  if (!headerRow || headerRow.cells.length <= 1) {
11652
- showNotice('無法刪除最後一欄');
12056
+ showNotice('這是表格的最後一欄。要刪除整張表格,請用 ⠿ → 刪除。');
11653
12057
  return;
11654
12058
  }
11655
12059
  currentBurst.history.snap('delete-col-pre');
@@ -11676,12 +12080,28 @@
11676
12080
  if (!liveTableEl) return;
11677
12081
  const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
11678
12082
  if (!liveRowEl) return;
12083
+ // v3.11 (Notion does what the gesture says): deleting the header row
12084
+ // promotes the first body row into it; with no body rows left the table
12085
+ // has nothing to keep, so the whole table block goes. The last BODY row
12086
+ // goes like any other — a header-only table is valid GFM.
11679
12087
  if (liveRowEl === headerRowOf(liveTableEl)) {
11680
- showNotice('無法刪除標題列');
11681
- return;
11682
- }
11683
- if (bodyRowsOf(liveTableEl).length <= 1) {
11684
- showNotice('無法刪除最後一列');
12088
+ const firstBody = bodyRowsOf(liveTableEl)[0];
12089
+ if (!firstBody) {
12090
+ hideTableEdgeMenu();
12091
+ const tableBlockEl = liveTableEl.closest('.ed-block');
12092
+ if (tableBlockEl) await deleteBlockViaGutter(tableBlockEl);
12093
+ return;
12094
+ }
12095
+ currentBurst.history.snap('delete-header-pre');
12096
+ const head = liveRowEl.cells;
12097
+ for (let i = 0; i < head.length; i++) {
12098
+ const src = firstBody.cells[i];
12099
+ head[i].innerHTML = src ? src.innerHTML : '';
12100
+ }
12101
+ refocusAwayFromRow(liveTableEl, firstBody);
12102
+ deleteRow(firstBody);
12103
+ currentBurst.history.snap('delete-header');
12104
+ hideTableEdgeMenu();
11685
12105
  return;
11686
12106
  }
11687
12107
  currentBurst.history.snap('delete-row-pre');
@@ -12220,12 +12640,10 @@
12220
12640
  // v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
12221
12641
  // buildTableGrip() for the S4 T1 detour into an inset grip and how it
12222
12642
  // was undone), vertically centred on its own row; no
12223
- // header special case. The
12224
- // one exception is a header-only table (no body rows): its single row
12225
- // is thead's only row, and dragging it away would empty the thead —
12226
- // serializeTable() would degrade it and the user's table would vanish
12227
- // from the page. Withhold the grip there instead.
12228
- if (rowEl && (rowEl !== headerRow || bodyRowsOf(tableEl).length > 0)) {
12643
+ // header special case. v3.11: that includes a header-only table — its
12644
+ // grip opens the row menu (刪除列 there removes the table); the DRAG is
12645
+ // what must not empty the thead, and performRowDrop() refuses it.
12646
+ if (rowEl) {
12229
12647
  gripRowTableEl = tableEl;
12230
12648
  gripRowEl = rowEl;
12231
12649
  const r = rowEl.getBoundingClientRect();
@@ -12547,6 +12965,8 @@
12547
12965
  // land on the header cell.
12548
12966
  const liveTableEl = await ensureTableBurstOpen(tableEl, true);
12549
12967
  if (!liveTableEl) return;
12968
+ // A header-only table has nowhere to move its one row to.
12969
+ if (!bodyRowsOf(liveTableEl).length) return;
12550
12970
  const liveRows = allRowsOf(liveTableEl);
12551
12971
  if (rowIndex < 0 || !liveRows[rowIndex]) return;
12552
12972
 
@@ -13060,23 +13480,12 @@
13060
13480
  // SECOND home position of its own — see performListItemDrop().
13061
13481
  if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
13062
13482
  const shape = spanListKinds(opEls);
13063
- // §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
13064
- // A mixed span would need a blank-line rule at the run/non-run seam — the
13065
- // li half has to stay tight while the non-li half has to keep its
13066
- // separators, and inside ONE commit range those two demands conflict. §3.6
13067
- // records that the spec does not have that rule and that inventing it in
13068
- // the last stage repeats the §3.4 marker-width error, so this refuses with
13069
- // 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
13070
- //
13071
- // DELIBERATELY ABOVE the second home position, which is the one place this
13072
- // function departs from Task 3's "answer a home position before every
13073
- // gate" rule, and the departure is the point: a seam objection depends on
13074
- // where the user AIMED, so refusing a put-it-back gesture with one would
13075
- // be a banner for a gesture in which nothing went wrong. MIXED is a
13076
- // property of the SET — it gives the same answer at every destination, so
13077
- // there is no aim that would have worked and saying so is the honest
13078
- // outcome. (The drop-on-a-member case is already out, above.)
13079
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
13483
+ // v3.11: a span mixing list items with other blocks moves too. It used to
13484
+ // refuse (§3.6's 2026-08-31 ruling: the blank-line rule at the run/non-run
13485
+ // seam had no answer). moveSpanAcrossLists() gives it one — list items in
13486
+ // a row are serialized as one tight list, everything else keeps its own
13487
+ // separators or gets one blank line — so the set has a destination.
13488
+ if (shape.anyLi && !shape.allLi) { await moveSpanAcrossLists(opEls, liveDestEl); return; }
13080
13489
  // S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
13081
13490
  // relocated verbatim (markers and ordinals are run-global), so the whole
13082
13491
  // run is re-serialized in the new span order instead — still ONE
@@ -13176,12 +13585,11 @@
13176
13585
  const seamRefusal = blockMoveSeamRefusal(
13177
13586
  { prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
13178
13587
  { prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
13179
- if (seamRefusal === 'source') {
13180
- refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
13181
- return;
13182
- }
13183
- if (seamRefusal === 'destination') {
13184
- refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
13588
+ // v3.11: neither seam refuses any more. Two lists that meet once this
13589
+ // block has left join; a block dropped between two items splits the list.
13590
+ // Both need list items re-serialized, so they take moveSpanAcrossLists().
13591
+ if (seamRefusal === 'source' || seamRefusal === 'destination') {
13592
+ await moveSpanAcrossLists(opEls, liveDestEl);
13185
13593
  return;
13186
13594
  }
13187
13595
  const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
@@ -13268,6 +13676,191 @@
13268
13676
  // contiguous slice of the document (listRunOf()'s own contract), so the
13269
13677
  // members occupy CONSECUTIVE slots of `run` and "move them" is one call to
13270
13678
  // reorderSpanRange() with a count instead of two paths.
13679
+ // v3.11: the ⠿ drop the narrower paths used to refuse — a paragraph into
13680
+ // the middle of a list (it splits), a list item out of its list or into
13681
+ // another one (it leaves / joins), a block whose removal makes two lists
13682
+ // meet (they join), a span mixing list items with other blocks, or items
13683
+ // of two lists. A list item takes its subtree along, as in Notion.
13684
+ //
13685
+ // ONE commit over the region the move touches: the moved span, the blocks
13686
+ // on either side of where it left and where it lands, and every list any of
13687
+ // those belongs to, whole. Inside it the blocks are laid out in their new
13688
+ // order; each maximal run of consecutive list items is re-serialized as one
13689
+ // list (serializeBlocks(), bystander bytes replayed, indents clamped so no
13690
+ // item is more than one level below the one above it), every other block
13691
+ // is its own source lines. Two blocks that were already neighbours keep the
13692
+ // lines that separated them; new neighbours get one blank line.
13693
+ async function moveSpanAcrossLists(opEls, destEl) {
13694
+ const all = allBlockEls();
13695
+ const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
13696
+ const indentOf = (el) => Number(el.getAttribute('data-indent')) || 0;
13697
+ const opLi = opEls.filter(isLi);
13698
+ const minLi = opLi.length ? spanMinIndent(opLi) : 0;
13699
+ const moving = opEls.slice();
13700
+ if (opLi.length) {
13701
+ subtreeBlocksAfter(moving[moving.length - 1], minLi).forEach((el) => {
13702
+ if (moving.indexOf(el) === -1) moving.push(el);
13703
+ });
13704
+ }
13705
+ const firstIdx = all.indexOf(moving[0]);
13706
+ const lastIdx = all.indexOf(moving[moving.length - 1]);
13707
+ if (firstIdx < 0 || lastIdx - firstIdx + 1 !== moving.length) {
13708
+ showError(DROPPED_GESTURE_MESSAGE, null, null); return;
13709
+ }
13710
+ if (destEl && moving.indexOf(destEl) !== -1) return;
13711
+ const rest = all.filter((el) => moving.indexOf(el) === -1);
13712
+ const di = destEl ? rest.indexOf(destEl) : rest.length;
13713
+ if (di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13714
+ if (di === firstIdx) return; // dropped where it already is
13715
+
13716
+ // The region: indices into `all`, widened until every list it touches is
13717
+ // inside it whole.
13718
+ const touched = moving.concat([all[firstIdx - 1], all[lastIdx + 1], rest[di - 1], rest[di]])
13719
+ .filter(Boolean);
13720
+ let lo = Math.min(...touched.map((el) => all.indexOf(el)));
13721
+ let hi = Math.max(...touched.map((el) => all.indexOf(el)));
13722
+ for (let grew = true; grew;) {
13723
+ grew = false;
13724
+ for (let k = lo; k <= hi; k++) {
13725
+ if (!isLi(all[k])) continue;
13726
+ const run = listRunOf(all[k]);
13727
+ if (!run.length) continue;
13728
+ const a = all.indexOf(run[0]);
13729
+ const b = all.indexOf(run[run.length - 1]);
13730
+ if (a < lo) { lo = a; grew = true; }
13731
+ if (b > hi) { hi = b; grew = true; }
13732
+ }
13733
+ }
13734
+ const regionEls = all.slice(lo, hi + 1);
13735
+ for (let k = 0; k < regionEls.length; k++) {
13736
+ if (blockOwnsNoLine(regionEls[k])) { refuseStructuralListEdit(noSourceLineMessageFor(regionEls[k])); return; }
13737
+ if (!blockRecOf(regionEls[k])) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13738
+ }
13739
+ const seenRuns = [];
13740
+ const runOf = new Map(); // each item's list BEFORE the move
13741
+ for (let k = 0; k < regionEls.length; k++) {
13742
+ if (!isLi(regionEls[k]) || runOf.has(regionEls[k])) continue;
13743
+ const run = listRunOf(regionEls[k]);
13744
+ seenRuns.push(run);
13745
+ run.forEach((el) => runOf.set(el, run));
13746
+ if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) { refuseStructuralListEdit(); return; }
13747
+ }
13748
+
13749
+ // The new order.
13750
+ const restRegion = regionEls.filter((el) => moving.indexOf(el) === -1);
13751
+ const at = destEl ? restRegion.indexOf(destEl) : restRegion.length;
13752
+ if (at < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13753
+ const seq = restRegion.slice(0, at).concat(moving, restRegion.slice(at));
13754
+ // Indents: a moved item lands at the level of the item it is dropped in
13755
+ // front of (top level otherwise), keeping its own subtree's shape; then
13756
+ // every item is clamped to at most one level below the item above it.
13757
+ const base = isLi(destEl) ? indentOf(destEl) : 0;
13758
+ const want = new Map();
13759
+ let prev = -1;
13760
+ seq.forEach((el) => {
13761
+ if (isLi(el)) {
13762
+ let ind = moving.indexOf(el) !== -1 ? base + (indentOf(el) - minLi) : indentOf(el);
13763
+ ind = Math.max(0, Math.min(ind, prev + 1));
13764
+ want.set(el, ind);
13765
+ prev = ind;
13766
+ } else {
13767
+ prev = -1;
13768
+ }
13769
+ });
13770
+ const firstRec = blockRecOf(regionEls[0]);
13771
+ const lastRec = blockRecOf(regionEls[regionEls.length - 1]);
13772
+ const carry = bystanderCarryOver(seq.filter(isLi));
13773
+ const origIndent = new Map(seq.filter(isLi).map((el) => [el, indentOf(el)]));
13774
+ mutateListRun(() => {
13775
+ seq.forEach((el, k) => {
13776
+ if (isLi(el)) {
13777
+ if (want.get(el) !== indentOf(el)) setBlockIndent(el, want.get(el));
13778
+ // A list boundary survives only where the same two blocks still meet.
13779
+ const before = seq[k - 1];
13780
+ if (isLi(before) && all[all.indexOf(el) - 1] !== before) el.removeAttribute('data-list-start');
13781
+ }
13782
+ });
13783
+ });
13784
+
13785
+ const out = [];
13786
+ const movedIds = moving.map((el) => el.getAttribute('data-block-id'));
13787
+ let movedFrom = null;
13788
+ let movedTo = null;
13789
+ const mark = (id, idx) => {
13790
+ if (movedIds.indexOf(id) !== -1) {
13791
+ if (movedFrom === null || idx < movedFrom) movedFrom = idx;
13792
+ if (movedTo === null || idx > movedTo) movedTo = idx;
13793
+ }
13794
+ };
13795
+ // Two items that were already neighbours in two DIFFERENT lists stay two
13796
+ // lists, with whatever separated them.
13797
+ const keepsApart = (x, y) => all[all.indexOf(x) + 1] === y && runOf.get(x) !== runOf.get(y);
13798
+ let lastEl = null;
13799
+ for (let k = 0; k < seq.length;) {
13800
+ let unit;
13801
+ let unitLines;
13802
+ let meta = null;
13803
+ if (isLi(seq[k])) {
13804
+ let j = k + 1;
13805
+ while (j < seq.length && isLi(seq[j]) && !keepsApart(seq[j - 1], seq[j])) j++;
13806
+ unit = seq.slice(k, j);
13807
+ const orig = runOf.get(unit[0]);
13808
+ if (orig && orig.length === unit.length && orig.every((el, n) => el === unit[n] &&
13809
+ moving.indexOf(el) === -1 && want.get(el) === origIndent.get(el))) {
13810
+ // A list the move did not touch keeps its own bytes.
13811
+ const r0 = blockRecOf(unit[0]);
13812
+ const r1 = blockRecOf(unit[unit.length - 1]);
13813
+ unitLines = lines.slice(r0.startLine - 1, r1.endLine);
13814
+ } else {
13815
+ const res = serializeListEls(unit, { carryOver: carry });
13816
+ unitLines = res.md.split('\n');
13817
+ meta = res.lineMeta;
13818
+ }
13819
+ k = j;
13820
+ } else {
13821
+ unit = [seq[k]];
13822
+ const r = blockRecOf(seq[k]);
13823
+ unitLines = lines.slice(r.startLine - 1, r.endLine);
13824
+ k++;
13825
+ }
13826
+ if (lastEl) {
13827
+ const a = blockRecOf(lastEl);
13828
+ const b = blockRecOf(unit[0]);
13829
+ if (all[all.indexOf(lastEl) + 1] === unit[0]) out.push(...lines.slice(a.endLine, b.startLine - 1));
13830
+ else out.push('');
13831
+ }
13832
+ const base0 = out.length;
13833
+ unitLines.forEach((l, n) => {
13834
+ mark(meta ? (meta[n] && meta[n].blockId) : unit[0].getAttribute('data-block-id'), base0 + n);
13835
+ });
13836
+ out.push(...unitLines);
13837
+ lastEl = unit[unit.length - 1];
13838
+ }
13839
+ let lead = 0;
13840
+ if (seq[0] !== regionEls[0] && firstRec.startLine > 1 &&
13841
+ String(lines[firstRec.startLine - 2]).trim() !== '') { out.unshift(''); lead = 1; }
13842
+ if (seq[seq.length - 1] !== regionEls[regionEls.length - 1] && lastRec.endLine < lines.length &&
13843
+ String(lines[lastRec.endLine]).trim() !== '') out.push('');
13844
+
13845
+ const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'));
13846
+ if (result.op === null) return;
13847
+ endBurstWithoutResolve();
13848
+ const prevLines = lines;
13849
+ lines = result.lines;
13850
+ if (movedFrom !== null) {
13851
+ declareCollapse({ startLine: firstRec.startLine + lead + movedFrom,
13852
+ endLine: firstRec.startLine + lead + movedTo });
13853
+ }
13854
+ const editRange = result.op
13855
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
13856
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
13857
+ delta: result.op.after.length - result.op.before.length }
13858
+ : undefined;
13859
+ if (!(await safeRerenderAll(editRange))) {
13860
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
13861
+ }
13862
+ }
13863
+
13271
13864
  async function performListItemDrop(liEls, destEl) {
13272
13865
  const liEl = liEls[0];
13273
13866
  const lastLiEl = liEls[liEls.length - 1];
@@ -13287,7 +13880,8 @@
13287
13880
  // convertBlockViaMenu() gives, through the same message: the commit
13288
13881
  // re-serializes ONE run's line range and a member outside it would be
13289
13882
  // rewritten by a range that does not cover it.
13290
- if (srcIdxs.indexOf(-1) !== -1) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
13883
+ // v3.11: items of two lists move together through moveSpanAcrossLists().
13884
+ if (srcIdxs.indexOf(-1) !== -1) { await moveSpanAcrossLists(liEls, destEl); return; }
13291
13885
  // ...and they must be CONSECUTIVE slots of it. Guaranteed today by
13292
13886
  // spanIsContiguous() plus listRunOf() returning a contiguous slice, and
13293
13887
  // checked anyway rather than asserted in prose: reorderSpanRange() takes a
@@ -13356,7 +13950,9 @@
13356
13950
  // {mode:'append'} target when a block follows the run. Task 5 gave them
13357
13951
  // their own message — the remedy is "stay inside your own list", which
13358
13952
  // neither seam message says.
13359
- if (insertAt < 0) { refuseStructuralListEdit(BLOCK_MOVE_OUT_OF_RUN_MESSAGE); return; }
13953
+ // v3.11: out of its own list — into another list, or anywhere else —
13954
+ // used to refuse with BLOCK_MOVE_OUT_OF_RUN_MESSAGE.
13955
+ if (insertAt < 0) { await moveSpanAcrossLists(liEls, destEl); return; }
13360
13956
  // `null` is the order-is-unchanged answer — the two home positions above
13361
13957
  // reach this by another road (an item whose next block is a sibling), and
13362
13958
  // it stays SILENT for the same reason.
@@ -13419,7 +14015,9 @@
13419
14015
  indent: clamped.has(el) ? clamped.get(el)
13420
14016
  : (Number(el.getAttribute('data-indent')) || 0),
13421
14017
  })))) {
13422
- refuseStructuralListEdit(BLOCK_MOVE_ORPHAN_MESSAGE);
14018
+ // v3.11: the generic move re-anchors instead of refusing (its indent
14019
+ // clamp keeps every item within one level of the item above it).
14020
+ await moveSpanAcrossLists(liEls, destEl);
13423
14021
  return;
13424
14022
  }
13425
14023
  // S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
@@ -13499,7 +14097,7 @@
13499
14097
  function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
13500
14098
  const ids = liEls.map((el) => el.getAttribute('data-block-id'));
13501
14099
  if (ids.indexOf(null) !== -1) return null;
13502
- const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
14100
+ const { lineMeta } = serializeListEls(span, { carryOver: carryOver || null });
13503
14101
  let first = -1;
13504
14102
  let last = -1;
13505
14103
  lineMeta.forEach((m, i) => {
@@ -13724,17 +14322,10 @@
13724
14322
  }
13725
14323
  // A plain press-release with no drag threshold crossed: open the menu
13726
14324
  // for whatever zone was hit at pointerdown.
14325
+ // v3.11: the header row's grip opens the same menu — deleting the header
14326
+ // promotes the first body row (runDeleteRow()).
13727
14327
  if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
13728
- else if (st.hit.isHeader) {
13729
- // The row menu's only item is "delete row", and the header row can
13730
- // never be deleted — showing it would just be an empty box. A plain
13731
- // click on the header grip highlights the row instead.
13732
- // clearEdgeHighlight() runs first so re-clicking a different header
13733
- // (or a different row's menu having been open) doesn't stack
13734
- // highlights within the same session.
13735
- clearEdgeHighlight();
13736
- highlightRow(st.hit.rowEl);
13737
- } else showRowMenu(st.hit.tableEl, st.hit.rowEl);
14328
+ else showRowMenu(st.hit.tableEl, st.hit.rowEl);
13738
14329
  });
13739
14330
 
13740
14331
  // Review fix (Critical): the browser/OS can ABORT a gesture outright —