@helping-ai-workflow/md2doc 3.9.1 → 3.11.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++) {
@@ -731,7 +731,7 @@
731
731
  const toolbarModel = window.md2docToolbarModel;
732
732
  const pasteMd = window.md2docPasteMd;
733
733
  const assetLib = window.md2docAsset;
734
- const docSourceLib = window.md2docDocSource;
734
+ const icons = window.md2docIcons;
735
735
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
736
736
  let lastParts = null;
737
737
  // v3.4.0 batch2 Task 6 fix round 1 (review F1) — true while `lastParts` is
@@ -1146,6 +1146,7 @@
1146
1146
 
1147
1147
  function setDirty() {
1148
1148
  document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
1149
+ paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
1149
1150
  }
1150
1151
 
1151
1152
  // setDirty() is push-driven: something has to call it. MEASURED by keeping
@@ -1202,9 +1203,9 @@
1202
1203
  }
1203
1204
 
1204
1205
  // ── banners (conflict / render-failed / save-failed) ──────────────────
1205
- // One shared, dismissible banner element. `actionLabel`+`onAction` add an
1206
- // extra button ahead of the always-present ✕ dismiss button (e.g. the
1207
- // conflict banner's "Reload"); omit them for a plain dismiss-only notice.
1206
+ // One shared banner element. `actionLabel`+`onAction` add an extra button
1207
+ // ahead of the ✕ (e.g. the conflict card's 重新載入). Since v3.10.0 callers
1208
+ // go through showError() / showNotice() below; only an error card has a ✕.
1208
1209
  let activeBanner = null;
1209
1210
  // Review recommendation 5 (2026-08-31): whether the banner on screen is a
1210
1211
  // structural REFUSAL — 「無法整批操作」 and its siblings — as opposed to a
@@ -1223,7 +1224,7 @@
1223
1224
  // dismissKeynavExitBanner()'s own comment for why Escape, specifically,
1224
1225
  // is the only OTHER thing allowed to clear it.
1225
1226
  let activeBannerIsKeynavExit = false;
1226
- function showBanner(message, actionLabel, onAction) {
1227
+ function showBanner(message, actionLabel, onAction, level) {
1227
1228
  if (activeBanner) { activeBanner.remove(); activeBanner = null; }
1228
1229
  // Reset unconditionally: refuseStructuralListEdit() sets it back to true
1229
1230
  // straight after its own call, so every OTHER caller gets false without
@@ -1265,7 +1266,20 @@
1265
1266
  closeToolbarMenu();
1266
1267
  const el = document.createElement('div');
1267
1268
  el.className = 'ed-conflict';
1269
+ // v3.10.0 (spec §5.2): two levels on the one .ed-conflict element (tests
1270
+ // and every dismiss path find banners by that class). 'error' is a card
1271
+ // under the toolbar that stays until closed (role alert): a problem that
1272
+ // can lose or skip an edit. 'notice' is a small box at the bottom that
1273
+ // fades after 4 seconds (role status): a refusal or an explanation.
1274
+ const lv = level === 'notice' ? 'notice' : 'error';
1275
+ el.setAttribute('data-level', lv);
1276
+ el.setAttribute('role', lv === 'error' ? 'alert' : 'status');
1277
+ const ico = document.createElement('span');
1278
+ ico.className = 'ed-msg-ico';
1279
+ ico.innerHTML = icons.svg(lv === 'error' ? 'circle-alert' : 'info', 18);
1280
+ el.appendChild(ico);
1268
1281
  const msg = document.createElement('span');
1282
+ msg.className = 'ed-msg-text';
1269
1283
  msg.textContent = message;
1270
1284
  el.appendChild(msg);
1271
1285
  if (actionLabel && onAction) {
@@ -1277,22 +1291,38 @@
1277
1291
  }
1278
1292
  const dismissBtn = document.createElement('button');
1279
1293
  dismissBtn.type = 'button';
1280
- dismissBtn.textContent = '✕';
1281
- dismissBtn.setAttribute('aria-label', 'Dismiss');
1282
- dismissBtn.addEventListener('click', () => {
1283
- el.remove();
1284
- if (activeBanner === el) {
1285
- activeBanner = null;
1286
- activeBannerIsRefusal = false;
1287
- activeBannerIsSwallow = false;
1288
- activeBannerIsKeynavExit = false;
1289
- }
1290
- });
1291
- el.appendChild(dismissBtn);
1294
+ dismissBtn.className = 'ed-msg-x';
1295
+ dismissBtn.innerHTML = icons.svg('x', 16);
1296
+ dismissBtn.setAttribute('aria-label', '關閉');
1297
+ dismissBtn.addEventListener('click', () => dismissBannerEl(el));
1298
+ // A notice has no close button: it goes away by itself.
1299
+ if (lv === 'error') el.appendChild(dismissBtn);
1292
1300
  document.body.appendChild(el);
1293
1301
  activeBanner = el;
1302
+ if (lv === 'notice') {
1303
+ // The fade runs the SAME cleanup the ✕ does, so the flags above go back
1304
+ // to "no banner" and the next Escape is not swallowed by a stale one
1305
+ // (Review Focus 3). Hovering pauses it; leaving gives at least 0.8s back.
1306
+ let left = 4000; let t0 = Date.now(); let timer = setTimeout(() => dismissBannerEl(el), left);
1307
+ el.addEventListener('mouseenter', () => { clearTimeout(timer); left -= Date.now() - t0; });
1308
+ el.addEventListener('mouseleave', () => { t0 = Date.now(); timer = setTimeout(() => dismissBannerEl(el), Math.max(left, 800)); });
1309
+ }
1294
1310
  return el;
1295
1311
  }
1312
+ // The one way a banner leaves the screen by the user's hand or by its own
1313
+ // timer: scoped to THIS element, so a newer banner that has replaced it is
1314
+ // left alone (and its flags with it).
1315
+ function dismissBannerEl(el) {
1316
+ el.remove();
1317
+ if (activeBanner === el) {
1318
+ activeBanner = null;
1319
+ activeBannerIsRefusal = false;
1320
+ activeBannerIsSwallow = false;
1321
+ activeBannerIsKeynavExit = false;
1322
+ }
1323
+ }
1324
+ function showError(message, actionLabel, onAction) { return showBanner(message, actionLabel, onAction, 'error'); }
1325
+ function showNotice(message) { return showBanner(message, null, null, 'notice'); }
1296
1326
 
1297
1327
  // Task 9 review I3: MEASURED AT THE TIME — `.ed-conflict` was
1298
1328
  // `position:fixed; top:0; z-index:999` against `.ed-toolbar`'s
@@ -1341,12 +1371,8 @@
1341
1371
  }
1342
1372
 
1343
1373
  function showConflictBanner() {
1344
- showBanner(
1345
- 'File changed on disk — reload to pick up external edits ' +
1346
- '(your unsaved changes will be lost).',
1347
- 'Reload',
1348
- () => location.reload()
1349
- );
1374
+ showError('這個檔案剛在別處被修改。重新載入會帶入新內容,並捨棄你在這裡還沒儲存的變更。',
1375
+ '重新載入', () => location.reload());
1350
1376
  }
1351
1377
 
1352
1378
  // F10 ——「一道沒閉合的圍欄把後面的內容吃掉了」那一句,單獨一個常數。
@@ -1547,24 +1573,24 @@
1547
1573
  body: JSON.stringify({ fileId: ED.fileId, content: lines.join('\n') }),
1548
1574
  });
1549
1575
  } catch (e) {
1550
- showBanner('Render failed — network error (' + describeFailure(e) +
1551
- '). Your edit was not applied.', null, null);
1576
+ showError('這次修改沒有套用:連不到編輯伺服器(' + describeFailure(e) +
1577
+ ')。文件維持修改前的樣子。', null, null);
1552
1578
  return false;
1553
1579
  }
1554
1580
  if (!res.ok) {
1555
1581
  const reason = await describeHttpFailure(res);
1556
- showBanner('Render failed — ' + reason + '. Your edit was not applied.', null, null);
1582
+ showError('這次修改沒有套用:' + reason + '。文件維持修改前的樣子。', null, null);
1557
1583
  return false;
1558
1584
  }
1559
1585
  let j;
1560
1586
  try {
1561
1587
  j = await res.json();
1562
1588
  } catch (e) {
1563
- showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
1589
+ showError('這次修改沒有套用:伺服器回傳的內容無法解析。文件維持修改前的樣子。', null, null);
1564
1590
  return false;
1565
1591
  }
1566
1592
  if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) {
1567
- showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
1593
+ showError('這次修改沒有套用:伺服器回傳的內容無法解析。文件維持修改前的樣子。', null, null);
1568
1594
  return false;
1569
1595
  }
1570
1596
  // Past every failure exit: this render is going to happen, so whatever
@@ -1775,7 +1801,7 @@
1775
1801
  // 者【現在看到的】那份文件。applyPatch()/applyFullRender() 自己不呼叫
1776
1802
  // showBanner(),所以這一句不會被同一次 render 裡的另一句蓋掉。
1777
1803
  if (swallowed) {
1778
- showBanner(SWALLOW_MESSAGE, null, null);
1804
+ showError(SWALLOW_MESSAGE, null, null);
1779
1805
  activeBannerIsSwallow = true; // 順序同 refuseStructuralListEdit():
1780
1806
  // showBanner() 自己會把旗標歸零。
1781
1807
  } else if (!nowUnclosed) {
@@ -2568,8 +2594,8 @@
2568
2594
  try {
2569
2595
  return await rerenderAll(editRange);
2570
2596
  } catch (e) {
2571
- showBanner('Render failed — unexpected error (' + describeFailure(e) +
2572
- '). Your edit was not applied.', null, null);
2597
+ showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
2598
+ ')。文件維持修改前的樣子。', null, null);
2573
2599
  return false;
2574
2600
  }
2575
2601
  }
@@ -3292,20 +3318,20 @@
3292
3318
  // listRunSupportsStructuralEdit() — see its comment for why this is a
3293
3319
  // parameter and not a second function). One constant so both call sites
3294
3320
  // stay byte-identical instead of two hand-typed copies drifting apart.
3295
- const NO_SOURCE_LINE_MESSAGE = '此項目沒有自己的來源行,無法刪除或直接編輯';
3321
+ const NO_SOURCE_LINE_MESSAGE = '此項目沒有自己的來源行,無法在這裡刪除或編輯。請用文字編輯器修改,存檔後這裡會自動更新。';
3296
3322
  // T7 fix round 1 (LOW-2): insertBlockBelow()'s own wording. A second
3297
3323
  // constant rather than a reuse, because the one above NAMES the two
3298
3324
  // operations it refuses — an insert that answered '無法刪除' would be
3299
3325
  // telling the user something that is not true of the button they pressed.
3300
- const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入區塊';
3326
+ const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入。請用文字編輯器修改,存檔後這裡會自動更新。';
3301
3327
  // The degraded-subtree wording (see blockRangeIsUnlocatable() above). Each
3302
3328
  // one NAMES ITS OPERATION exactly as the pair above does — that is the whole
3303
3329
  // reason the pair above is a pair — and differs from it only in the clause
3304
3330
  // that states the cause. Deliberately says nothing about the refusal being
3305
3331
  // temporary: nothing lifts it, so wording like 「已暫停」 would promise a state
3306
3332
  // change that never comes.
3307
- const UNLOCATABLE_LINE_MESSAGE = '這段巢狀清單對不到自己的來源行,無法刪除或直接編輯';
3308
- const UNLOCATABLE_LINE_INSERT_MESSAGE = '這段巢狀清單對不到自己的來源行,無法在其後插入區塊';
3333
+ const UNLOCATABLE_LINE_MESSAGE = '這段巢狀清單對不到自己的來源行,無法在這裡刪除或編輯。請用文字編輯器修改,存檔後這裡會自動更新。';
3334
+ const UNLOCATABLE_LINE_INSERT_MESSAGE = '這段巢狀清單對不到自己的來源行,無法在其後插入。請用文字編輯器修改,存檔後這裡會自動更新。';
3309
3335
  // Both refusals go through one chooser each, so a call site cannot pick the
3310
3336
  // right guard and the wrong sentence.
3311
3337
  function noSourceLineMessageFor(blockEl) {
@@ -3419,10 +3445,37 @@
3419
3445
  return out;
3420
3446
  }
3421
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
+
3422
3475
  function canWysiwygForLi(blockEl) {
3423
3476
  if (!blockEl) return false;
3424
3477
  if (blockOwnsNoLine(blockEl)) return false;
3425
- const res = listMd.serializeBlocks([blockEl]);
3478
+ const res = serializeListEls([blockEl]);
3426
3479
  return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
3427
3480
  }
3428
3481
 
@@ -4237,6 +4290,38 @@
4237
4290
  blockEl.appendChild(buildGutterInsertButton());
4238
4291
  blockEl.appendChild(buildGutterHandle());
4239
4292
  });
4293
+ alignGutterTops(blockEls);
4294
+ }
4295
+
4296
+ // The ⠿ / + pair sits on the block's first line of text, whatever the
4297
+ // block's own margin and line-height (a heading line is 49.5px tall, a
4298
+ // paragraph's 24.75px). Measured from the first real text node's first
4299
+ // line box, so tables, list items and code all land on their first row; a
4300
+ // block with no text (an image, a bare diagram) keeps top 0. armEditables()
4301
+ // also runs on a detached <template> fragment (the partial re-render path),
4302
+ // where nothing has a layout yet: then it waits one frame and measures the
4303
+ // blocks that have been connected by then.
4304
+ function alignGutterTops(blockEls) {
4305
+ if (!blockEls.length || !blockEls[0].isConnected) {
4306
+ if (blockEls.length) requestAnimationFrame(() => alignGutterTops(blockEls.filter((b) => b.isConnected)));
4307
+ return;
4308
+ }
4309
+ for (const b of blockEls) {
4310
+ const walker = document.createTreeWalker(b, NodeFilter.SHOW_TEXT, {
4311
+ acceptNode: (n) => (n.data.trim() && !n.parentElement.closest('.ed-handle, .ed-insert, .ed-li-marker, svg'))
4312
+ ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
4313
+ });
4314
+ const t = walker.nextNode();
4315
+ let top = 0;
4316
+ if (t) {
4317
+ const rg = document.createRange();
4318
+ rg.selectNodeContents(t);
4319
+ const line = rg.getClientRects()[0];
4320
+ const handle = b.querySelector('.ed-handle');
4321
+ if (line && handle) top = Math.round(line.top + line.height / 2 - b.getBoundingClientRect().top - handle.offsetHeight / 2);
4322
+ }
4323
+ b.style.setProperty('--ed-pair-top', top + 'px');
4324
+ }
4240
4325
  }
4241
4326
 
4242
4327
  // A fresh ⠿ button per block — deliberately NOT wired with its own
@@ -4248,7 +4333,7 @@
4248
4333
  const el = document.createElement('button');
4249
4334
  el.type = 'button';
4250
4335
  el.className = 'ed-handle';
4251
- el.textContent = '⠿';
4336
+ el.innerHTML = icons.svg('grip-vertical', 16);
4252
4337
  el.setAttribute('aria-label', '區塊選項');
4253
4338
  // v2.11.1: a <button> is a sequential focus stop, and there is one of
4254
4339
  // these plus one + standing immediately after EVERY block — so any Tab
@@ -4703,7 +4788,7 @@
4703
4788
  const el = document.createElement('button');
4704
4789
  el.type = 'button';
4705
4790
  el.className = 'ed-insert';
4706
- el.textContent = '+';
4791
+ el.innerHTML = icons.svg('plus', 16);
4707
4792
  el.setAttribute('aria-label', '插入區塊');
4708
4793
  // Not a tab stop — see buildGutterHandle() above for the whole reason.
4709
4794
  el.setAttribute('tabindex', '-1');
@@ -4963,7 +5048,7 @@
4963
5048
  if (!document.body.contains(blockEl)) {
4964
5049
  liveBlockEl = reresolveBlockEl(identity) ||
4965
5050
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
4966
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
5051
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
4967
5052
  }
4968
5053
  // T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
4969
5054
  // for the same reason and against the same LIVE block. A block that owns
@@ -5292,14 +5377,14 @@
5292
5377
  if (!document.body.contains(blockEl)) {
5293
5378
  liveBlockEl = reresolveBlockEl(identity) ||
5294
5379
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
5295
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5380
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5296
5381
  }
5297
5382
  // A block that owns no source line has an INVERTED range
5298
5383
  // (endLine === startLine - 1) and every commit helper handed one does
5299
5384
  // something plausible and wrong.
5300
5385
  if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
5301
5386
  const rec = blockRecOf(liveBlockEl);
5302
- if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5387
+ if (!rec) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5303
5388
  if (anchor) anchor.line = rec.startLine;
5304
5389
 
5305
5390
  const res = selectionLib
@@ -5336,7 +5421,7 @@
5336
5421
  const els = [];
5337
5422
  for (let i = 0; i < members.length; i++) {
5338
5423
  const el = blockElById(members[i].id);
5339
- if (!el) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5424
+ if (!el) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5340
5425
  // Belt to membersOf()'s own braces: selection.js excludes a block that
5341
5426
  // owns no line from every member set it builds, so this is unreachable —
5342
5427
  // but "another module filters it" is an argument about another file's
@@ -5369,58 +5454,20 @@
5369
5454
 
5370
5455
  const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
5371
5456
  const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
5372
- const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
5373
5457
  const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
5374
- // 刪除's own answer to a mixed span: it accepts one whose lists are all
5375
- // covered whole (see listsCoveredWhole()), so its refusal names the fix.
5376
- const BATCH_PARTIAL_LIST_DELETE_MESSAGE = '刪除時清單只選到一部分:請把整個清單選進來,或只選清單項目';
5377
- // S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
5378
- //
5379
- // Task 3 raised all of these with a single provisional constant
5380
- // (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
5381
- // owned the final wording. They are three different problems with three
5382
- // OPPOSITE remedies, so one sentence for all of them sends two thirds of
5383
- // the users to fix the wrong end of their own gesture:
5384
- // * the SOURCE seam — the block cannot leave where it is, because the two
5385
- // lists it stands between would join into one loose run. No destination
5386
- // helps; the remedy is not to move it (or to move something else).
5387
- // * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
5388
- // is to aim somewhere else, which is the exact opposite advice.
5389
- // * OUT OF RUN — a list item may only move among its own §3.8 siblings.
5390
- // The remedy is to stay inside its own list.
5391
- // Naming follows the BATCH_*_MESSAGE convention above. None of the three is
5392
- // a substring of another, of §4.3's own gate message, or of
5393
- // BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
5394
- // refusal fired rather than that some banner mentioning 清單 appeared.
5395
- 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.
5396
5465
  const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
5397
- const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
5398
- // S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
5399
- // of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
5400
- // removal half of the move through clampIndents()'s `{ removed: true }`),
5401
- // which answers every SOURCE-side orphan. What no removal-clamp can reach is
5402
- // the INSERTION half: a BATCH whose last member is SHALLOWER than the block
5403
- // it lands before leaves that block with no parent chain above it, at the
5404
- // NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
5405
- // indent array of length 2..6 and depth 0..3, every operand-set size and
5406
- // every destination the run gate admits: 4067 drops, 1425 refused by Task
5407
- // 4's predicate, 14 still unanchored after the clamp — every one of them a
5408
- // multi-block set breaking at the block immediately after the landed set.
5409
- // (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
5410
- //
5411
- // RE-WORDED with the narrowing, and the wording is the point: Task 4's
5412
- // 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
5413
- // was true while the capability was missing and is a lie now that it is not.
5414
- // What is left is a DESTINATION objection with the same remedy
5415
- // BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
5416
- // end of the gesture is at fault. Still not a substring of any other refusal
5417
- // in this file, which is what lets a test assert WHICH one fired.
5418
- const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
5419
5466
  // 轉換成's own refusals are NOT declared here. They are per block TYPE, and
5420
5467
  // they are the same ruling §3.7 applies to a single block's ⠿ — so they live
5421
5468
  // with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
5422
5469
  // toggleGutterMenu(), where the menu-hiding call site can share them. The
5423
- // four constants above are shape-level and belong to every batch operation.
5470
+ // constants above are shape-level and belong to every batch operation.
5424
5471
 
5425
5472
  function blockElById(id) {
5426
5473
  return document.querySelector('.ed-block[data-block-id="' + id + '"]');
@@ -5670,9 +5717,11 @@
5670
5717
  // null (dropped gesture, no source
5671
5718
  // line, empty/gapped/no-element
5672
5719
  // batch)
5673
- // 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
5674
- // 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
5675
- // 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)
5676
5725
  // 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
5677
5726
  // 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
5678
5727
  // — six, not three, plus `result.op === null` (a commit that wrote nothing)
@@ -5718,41 +5767,20 @@
5718
5767
  const els = operands.els;
5719
5768
  const recs = operands.recs;
5720
5769
  const shape = spanListKinds(els);
5721
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
5722
- // §3.7 / §7 withhold 轉換成 from some block types ENTIRELY —
5723
- // toggleGutterMenu() hides the item outright, and the list plus every
5724
- // reason is in BATCH_CONVERT_WITHHELD_MESSAGES above it. This is the SAME
5725
- // predicate, asked of a SET: none of those reasons stops applying because
5726
- // the block happens to be one member of a span, and a batch whose grip is a
5727
- // paragraph reaches the withheld member anyway. MEASURED, before this gate
5728
- // existed: a table span wrote '> | A | B |' … / '- | A | B |' with its other
5729
- // rows as continuations, an hr span wrote '- ---', an html span wrote
5730
- // '> <div>x</div>'. Nothing is LOST in any of them (they all still lex as
5731
- // what they were) — which is exactly why it is so quiet: the single-block ⠿
5732
- // and the batch ⠿ give OPPOSITE answers to the same question with nothing
5733
- // on screen saying so, the shape §3.6's 2026-08-31 「不得另寫一條」 ruling
5734
- // exists to prevent. ⇒ REFUSE, with a banner (§3.6: 靜默不動作是缺陷), file
5735
- // byte-identical.
5736
- //
5737
- // The banner names the type that is ACTUALLY in the span, not one of the
5738
- // four shape-level messages above: BATCH_MIXED names list items vs other
5739
- // blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
5740
- // names non-contiguity — either would name something the user's selection
5741
- // has not done. So would telling a user with an hr in the selection that it
5742
- // contains a table.
5743
- //
5744
- // SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
5745
- // holding any of these types are measured correct and have no equivalent
5746
- // objection — they do not need a target that can carry cells, or a marker
5747
- // to strip — so the predicate deliberately does NOT reach
5748
- // resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
5749
- // those five cells as APPLYING so a widening shows up as a failure too.
5750
- // Placed BELOW the mixed gate so no span that already refuses changes which
5751
- // banner it gets.
5752
- const withheld = shape.kinds.filter(convertWithheldFor)[0];
5753
- if (withheld) {
5754
- refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
5755
- 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);
5756
5784
  }
5757
5785
 
5758
5786
  // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
@@ -5761,25 +5789,20 @@
5761
5789
  // is exactly what listRunOf() returns (the outermost run PLUS every
5762
5790
  // descendant of its members) — see deleteListItemsViaGutter()'s own note.
5763
5791
  //
5764
- // ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
5765
- // refusals below and, further down, of stripMarker(): a multi-line li must
5766
- // report §4.1's 「此清單含不支援的格式,無法調整結構」 and not
5767
- // convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
5768
- // would get if stripMarker() saw it first (that function refuses a
5769
- // multi-line li too, for its own, narrower reason). The runtime scenario
5770
- // 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
5771
- // 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.
5772
5795
  //
5773
- // A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's own
5774
- // text or line count, so a multi-line li refuses as a TARGET while
5775
- // remaining a perfectly good bystander. S3 Task 6 passes the WHOLE operand
5776
- // set as the target list — every member is being rewritten, so every member
5777
- // 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.
5778
5801
  let liRun = null;
5779
5802
  if (shape.allLi) {
5780
5803
  liRun = batchRunOf(els);
5781
5804
  if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
5782
- if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
5805
+ if (!listRunSupportsStructuralEdit(liRun, els, { replaysTargets: true })) { refuseStructuralListEdit(); return; }
5783
5806
  }
5784
5807
 
5785
5808
  // S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
@@ -5955,7 +5978,12 @@
5955
5978
  const els = operands.els;
5956
5979
  const recs = operands.recs;
5957
5980
  const shape = spanListKinds(els);
5958
- 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
+ }
5959
5987
 
5960
5988
  if (shape.allLi) {
5961
5989
  // recs[0].startLine is unchanged by this commit — duplicateListItems()'s
@@ -6010,6 +6038,60 @@
6010
6038
  // of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
6011
6039
  // a refusal (nothing was duplicated, the block is still where it was) apart
6012
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
+
6013
6095
  async function duplicateListItems(liEls, recs) {
6014
6096
  // Every list batch is one run's problem — see batchRunOf().
6015
6097
  const run = batchRunOf(liEls);
@@ -6021,7 +6103,7 @@
6021
6103
  // it adds the item's lines over again), so a multi-line li refuses as a
6022
6104
  // TARGET while remaining a perfectly good bystander — and in a batch EVERY
6023
6105
  // member is a target.
6024
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
6106
+ if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
6025
6107
  // Captured BEFORE the copy enters the span. The copy carries the
6026
6108
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
6027
6109
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
@@ -6204,15 +6286,13 @@
6204
6286
  const els = operands.els;
6205
6287
  const recs = operands.recs;
6206
6288
  const shape = spanListKinds(els);
6207
- // A mixed span is deletable exactly when it covers every list it touches
6208
- // WHOLE: nothing of those lists survives, so there is no run to renumber
6209
- // or clamp, and the span is one contiguous line range like any non-list
6210
- // span -- the commitRangeRemoval() branch below, one undo op. A list
6211
- // covered only in PART would need its survivors re-serialized in the same
6212
- // commit as the non-list removal, which is still refused.
6213
- if (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) {
6214
- refuseStructuralListEdit(BATCH_PARTIAL_LIST_DELETE_MESSAGE); return;
6215
- }
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));
6216
6296
  // Fix round 1, item 2: the block immediately ABOVE the span, read out of
6217
6297
  // the PRE-commit `blocks` and carried to the wrapper. It cannot be
6218
6298
  // recovered afterwards from the hole's coordinate — see
@@ -6244,6 +6324,10 @@
6244
6324
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
6245
6325
  // below corrupts a list three separate ways — see
6246
6326
  // deleteListItemsViaGutter() for the measurements and the routing.
6327
+ if (acrossLists) {
6328
+ return (await deleteSpanAcrossLists(els, recs))
6329
+ ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
6330
+ }
6247
6331
  if (shape.allLi) {
6248
6332
  return (await deleteListItemsViaGutter(els))
6249
6333
  ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
@@ -6313,6 +6397,94 @@
6313
6397
  // of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
6314
6398
  // a refusal (nothing was removed, the block is still where it was) apart from
6315
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
+
6316
6488
  async function deleteListItemsViaGutter(liEls) {
6317
6489
  // Every list batch is one run's problem — see batchRunOf().
6318
6490
  const run = batchRunOf(liEls);
@@ -6323,7 +6495,7 @@
6323
6495
  // re-indent are covered, not just the target's own. Deleting is NOT
6324
6496
  // column-only: it removes the target's lines outright, so a multi-line
6325
6497
  // target refuses per §4.1 — and in a batch every member is a target.
6326
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
6498
+ if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
6327
6499
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
6328
6500
  if (!range) return false;
6329
6501
  // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
@@ -6499,6 +6671,162 @@
6499
6671
  };
6500
6672
  }
6501
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
+
6502
6830
  async function convertListItemsType(liEls, run, recs, target) {
6503
6831
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
6504
6832
  if (!range) return;
@@ -6680,13 +7008,13 @@
6680
7008
  // back through escapeText() — see convertListItemsType()'s note.
6681
7009
  const carry = bystanderCarryOver(before.concat(after));
6682
7010
  const pieces = [];
6683
- if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
7011
+ if (before.length) pieces.push(serializeListEls(before, { carryOver: carry }).md);
6684
7012
  // Which piece the converted blocks are — the collapse range below counts
6685
7013
  // emitted LINES up to it, and "0 or 1" is only true while `before` is the
6686
7014
  // one optional piece ahead of it.
6687
7015
  const convertedPieceIdx = pieces.length;
6688
7016
  pieces.push(convertedMd);
6689
- if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
7017
+ if (after.length) pieces.push(serializeListEls(after, { carryOver: carry }).md);
6690
7018
  let md = pieces.join('\n\n');
6691
7019
 
6692
7020
  // §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
@@ -7088,7 +7416,7 @@
7088
7416
  // source range.
7089
7417
  const carry = bystanderCarryOver(runEls, editedLiEl);
7090
7418
  const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
7091
- listMd.serializeBlocks(runEls, { carryOver: carry });
7419
+ serializeListEls(runEls, { carryOver: carry });
7092
7420
  // Refuse if the EDITED li itself has unsupported inline content.
7093
7421
  // Show banner + teardown + rerenderAll (file is untouched, burst never
7094
7422
  // wrote to `lines`) + return false — this path does NOT fall back to
@@ -7106,7 +7434,7 @@
7106
7434
  // not before: the user can reach exactly that through the ⠿ menu.
7107
7435
  const editedIdStr = String(burst.blockId);
7108
7436
  if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
7109
- showBanner('含不支援的格式,改用原始碼編輯', null, null);
7437
+ showNotice('含不支援的格式,改用原始碼編輯');
7110
7438
  endBurstWithoutResolve();
7111
7439
  await safeRerenderAll();
7112
7440
  return false;
@@ -7114,7 +7442,15 @@
7114
7442
  const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
7115
7443
  if (!range) { endBurstWithoutResolve(); return true; }
7116
7444
  let commitMd, commitStart, commitEnd;
7117
- 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) {
7118
7454
  // The run contains SOME unsupported content — commit only the edited
7119
7455
  // li's own line range so nothing else in the run is round-tripped
7120
7456
  // through the tight (blank-line-collapsed) runMd.
@@ -7222,7 +7558,7 @@
7222
7558
  // `lines`), and return false so the caller (switchAwayFrom(), on
7223
7559
  // behalf of whatever triggered this resolution) aborts instead of
7224
7560
  // proceeding as if the burst resolved cleanly.
7225
- showBanner('含不支援的格式,改用原始碼編輯', null, null);
7561
+ showNotice('含不支援的格式,改用原始碼編輯');
7226
7562
  endBurstWithoutResolve();
7227
7563
  openRawEditor(burst.blockEl);
7228
7564
  return false;
@@ -7543,6 +7879,10 @@
7543
7879
  }));
7544
7880
  };
7545
7881
  window.__edTestForceRerender = function () { return rerenderAll(); };
7882
+ // v3.10.0: a notice has no close button (it fades after 4s). Tests that only
7883
+ // need the banner gone before their next gesture take it down through the
7884
+ // same dismissBannerEl() the fade timer and the ✕ use.
7885
+ window.__edTestDismissBanner = function () { if (activeBanner) dismissBannerEl(activeBanner); };
7546
7886
  window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
7547
7887
 
7548
7888
  // v3.0.2: computeIndentClamp() lives past this file's node early-exit
@@ -7944,7 +8284,7 @@
7944
8284
  async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
7945
8285
  const span = runEls || [];
7946
8286
  const presetRange = opts && opts.presetRange;
7947
- const { md } = listMd.serializeBlocks(span, { carryOver: (opts && opts.carryOver) || null });
8287
+ const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
7948
8288
  // The run's line range is read back off its own li blocks' ids — which
7949
8289
  // requires at least one li to still BE there. A caller whose mutation
7950
8290
  // removed the run's last item therefore captures the range BEFORE mutating
@@ -8011,7 +8351,7 @@
8011
8351
  // can emit a different number of lines than a re-serialized one would (a
8012
8352
  // blank continuation is dropped on the re-serialize path but replayed
8013
8353
  // verbatim here), and this answer is an index INTO those lines.
8014
- const { lineMeta } = listMd.serializeBlocks(runEls, { carryOver: carryOver || null });
8354
+ const { lineMeta } = serializeListEls(runEls, { carryOver: carryOver || null });
8015
8355
  // A provisional block (split's new item) has no id yet, so it cannot be
8016
8356
  // found by one — fall back to counting the lines emitted before it.
8017
8357
  if (targetId === null) {
@@ -8020,7 +8360,16 @@
8020
8360
  const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
8021
8361
  let n = 0;
8022
8362
  lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
8023
- 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;
8024
8373
  }
8025
8374
  const idx = lineMeta.findIndex((m) => m.blockId === targetId);
8026
8375
  if (idx === -1) return null;
@@ -8107,13 +8456,21 @@
8107
8456
  // excluding a burst whose surface has actually been edited.
8108
8457
  function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
8109
8458
  if (!runEls || !runEls.length) return false;
8110
- const res = listMd.serializeBlocks(runEls);
8459
+ const res = serializeListEls(runEls);
8111
8460
  const multi = res.multiLineBlockIds || [];
8112
8461
  // Anything OTHER than MULTILINE still refuses run-wide, unchanged.
8113
8462
  for (let i = 0; i < res.unsupported.length; i++) {
8114
8463
  if (res.unsupported[i] !== 'MULTILINE') return false;
8115
8464
  }
8116
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;
8117
8474
  // S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
8118
8475
  // one run and every one of them has to clear §4.1's multi-line gate, not
8119
8476
  // just the one the ⠿ was pressed on. An empty array is the same question as
@@ -9060,6 +9417,114 @@
9060
9417
  // what makes 「段落 no-op」 a real no-op instead of a re-serialization that
9061
9418
  // could move bytes nobody asked it to, and it is why this is ONE
9062
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
+
9063
9528
  async function changeHeadingDepthsInSpan(els, recs, dir) {
9064
9529
  const first = recs[0];
9065
9530
  const last = recs[recs.length - 1];
@@ -9117,13 +9582,14 @@
9117
9582
  const els = operands.els;
9118
9583
  const recs = operands.recs;
9119
9584
  const shape = spanListKinds(els);
9120
- // §3.6's 2026-08-31 ruling, inherited rather than re-invented. NOTE: this
9121
- // CONTRADICTS the S3 plan's Task 7 text ("a batch containing both kinds
9122
- // applies each rule to its own kind") — see the Task 7 carry. A mixed span
9123
- // would need the run's survivors re-serialized at the same time as lines
9124
- // outside the run are rewritten, i.e. two commits and therefore two undo
9125
- // ops, which §3.4's 「一次使用者手勢 = 恰好一個 undo op」 forbids.
9126
- 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
+ }
9127
9593
  if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
9128
9594
  await changeHeadingDepthsInSpan(els, recs, dir);
9129
9595
  }
@@ -9222,7 +9688,7 @@
9222
9688
  // and shadowed each other silently (last-declaration-wins), so the
9223
9689
  // no-source-line callers pass their own text instead of a second function.
9224
9690
  function refuseStructuralListEdit(message) {
9225
- showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
9691
+ showNotice(message || '此清單含不支援的格式,無法調整結構');
9226
9692
  // Review recommendation 5: mark it, so the next SUCCESSFUL render clears
9227
9693
  // it (dismissRefusalBanner(), called from rerenderAll()). Set after the
9228
9694
  // showBanner() call, which resets the flag for every other caller.
@@ -10310,7 +10776,7 @@
10310
10776
  const burst = currentBurst;
10311
10777
  if (!burst || burst.blockType !== 'table' || burst.editEl !== tableEl || !grid.length) return false;
10312
10778
  const width = grid.reduce((w, row) => Math.max(w, row.length), 0);
10313
- if (grid.length > 500 || width > 50) { showBanner(GRID_PASTE_TOO_BIG_MESSAGE); return true; }
10779
+ if (grid.length > 500 || width > 50) { showNotice(GRID_PASTE_TOO_BIG_MESSAGE); return true; }
10314
10780
  const origin = burst.cellRange
10315
10781
  ? (() => { const rc = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10316
10782
  return { r: rc.top, c: rc.left }; })()
@@ -10423,7 +10889,7 @@
10423
10889
  const liveSrc = srcRec
10424
10890
  ? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
10425
10891
  dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
10426
- if (!dest) { showBanner(DROPPED_GESTURE_MESSAGE); return; }
10892
+ if (!dest) { showError(DROPPED_GESTURE_MESSAGE); return; }
10427
10893
  }
10428
10894
  if (x !== null) arrowGoalX = x;
10429
10895
  arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
@@ -10991,7 +11457,7 @@
10991
11457
  // Dropping is still the right answer (see the S1 comment above); saying
10992
11458
  // nothing was not.
10993
11459
  if (!liveTableEl) {
10994
- showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
11460
+ showError(DROPPED_GESTURE_MESSAGE, null, null); return null;
10995
11461
  }
10996
11462
  }
10997
11463
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
@@ -11059,7 +11525,7 @@
11059
11525
  // 15 call sites): can't relocate it on the live table -> drop the
11060
11526
  // gesture and say so, never guess.
11061
11527
  if (!targetCell) {
11062
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
11528
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
11063
11529
  return;
11064
11530
  }
11065
11531
  currentBurst.history.snap('insert-col-pre');
@@ -11111,7 +11577,7 @@
11111
11577
  // can't relocate it on the live table -> drop the gesture and say so,
11112
11578
  // never guess.
11113
11579
  if (afterRowIndex >= 0 && !afterRow) {
11114
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
11580
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
11115
11581
  return;
11116
11582
  }
11117
11583
  currentBurst.history.snap('insert-row-pre');
@@ -11587,7 +12053,7 @@
11587
12053
  if (!liveTableEl) return;
11588
12054
  const headerRow = headerRowOf(liveTableEl);
11589
12055
  if (!headerRow || headerRow.cells.length <= 1) {
11590
- showBanner('無法刪除最後一欄', null, null);
12056
+ showNotice('這是表格的最後一欄。要刪除整張表格,請用 ⠿ → 刪除。');
11591
12057
  return;
11592
12058
  }
11593
12059
  currentBurst.history.snap('delete-col-pre');
@@ -11614,12 +12080,28 @@
11614
12080
  if (!liveTableEl) return;
11615
12081
  const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
11616
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.
11617
12087
  if (liveRowEl === headerRowOf(liveTableEl)) {
11618
- showBanner('無法刪除標題列', null, null);
11619
- return;
11620
- }
11621
- if (bodyRowsOf(liveTableEl).length <= 1) {
11622
- showBanner('無法刪除最後一列', null, null);
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();
11623
12105
  return;
11624
12106
  }
11625
12107
  currentBurst.history.snap('delete-row-pre');
@@ -12158,12 +12640,10 @@
12158
12640
  // v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
12159
12641
  // buildTableGrip() for the S4 T1 detour into an inset grip and how it
12160
12642
  // was undone), vertically centred on its own row; no
12161
- // header special case. The
12162
- // one exception is a header-only table (no body rows): its single row
12163
- // is thead's only row, and dragging it away would empty the thead —
12164
- // serializeTable() would degrade it and the user's table would vanish
12165
- // from the page. Withhold the grip there instead.
12166
- 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) {
12167
12647
  gripRowTableEl = tableEl;
12168
12648
  gripRowEl = rowEl;
12169
12649
  const r = rowEl.getBoundingClientRect();
@@ -12485,6 +12965,8 @@
12485
12965
  // land on the header cell.
12486
12966
  const liveTableEl = await ensureTableBurstOpen(tableEl, true);
12487
12967
  if (!liveTableEl) return;
12968
+ // A header-only table has nowhere to move its one row to.
12969
+ if (!bodyRowsOf(liveTableEl).length) return;
12488
12970
  const liveRows = allRowsOf(liveTableEl);
12489
12971
  if (rowIndex < 0 || !liveRows[rowIndex]) return;
12490
12972
 
@@ -12936,7 +13418,7 @@
12936
13418
  const sourceEl = els[st.sourceIndex];
12937
13419
  const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
12938
13420
  if (!sourceEl || (target.mode !== 'append' && !destEl)) {
12939
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
13421
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
12940
13422
  return;
12941
13423
  }
12942
13424
  const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
@@ -12970,7 +13452,7 @@
12970
13452
  if (destEl && !document.body.contains(destEl)) {
12971
13453
  liveDestEl = reresolveBlockEl(destIdentity) ||
12972
13454
  (destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
12973
- if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13455
+ if (!liveDestEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
12974
13456
  }
12975
13457
  // ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
12976
13458
  // §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
@@ -12998,23 +13480,12 @@
12998
13480
  // SECOND home position of its own — see performListItemDrop().
12999
13481
  if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
13000
13482
  const shape = spanListKinds(opEls);
13001
- // §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
13002
- // A mixed span would need a blank-line rule at the run/non-run seam — the
13003
- // li half has to stay tight while the non-li half has to keep its
13004
- // separators, and inside ONE commit range those two demands conflict. §3.6
13005
- // records that the spec does not have that rule and that inventing it in
13006
- // the last stage repeats the §3.4 marker-width error, so this refuses with
13007
- // 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
13008
- //
13009
- // DELIBERATELY ABOVE the second home position, which is the one place this
13010
- // function departs from Task 3's "answer a home position before every
13011
- // gate" rule, and the departure is the point: a seam objection depends on
13012
- // where the user AIMED, so refusing a put-it-back gesture with one would
13013
- // be a banner for a gesture in which nothing went wrong. MIXED is a
13014
- // property of the SET — it gives the same answer at every destination, so
13015
- // there is no aim that would have worked and saying so is the honest
13016
- // outcome. (The drop-on-a-member case is already out, above.)
13017
- 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; }
13018
13489
  // S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
13019
13490
  // relocated verbatim (markers and ordinals are run-global), so the whole
13020
13491
  // run is re-serialized in the new span order instead — still ONE
@@ -13039,7 +13510,7 @@
13039
13510
  const sj = live.indexOf(opEls[opEls.length - 1]);
13040
13511
  const rest = live.filter((el) => opEls.indexOf(el) === -1);
13041
13512
  const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
13042
- if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13513
+ if (si < 0 || sj < 0 || di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13043
13514
  // §3.3's set is contiguous in block order — resolveGutterOperands()'s own
13044
13515
  // gate — so its members' lines plus the separators between them are ONE
13045
13516
  // range, which is what makes a batch move the same single commitRangeEdit
@@ -13047,7 +13518,7 @@
13047
13518
  const srcRec = spanMoveRange(opRecs);
13048
13519
  const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
13049
13520
  if (!srcRec || (liveDestEl && !destRec)) {
13050
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
13521
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
13051
13522
  return;
13052
13523
  }
13053
13524
  // A destination that owns no source line has an INVERTED range, and its
@@ -13114,12 +13585,11 @@
13114
13585
  const seamRefusal = blockMoveSeamRefusal(
13115
13586
  { prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
13116
13587
  { prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
13117
- if (seamRefusal === 'source') {
13118
- refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
13119
- return;
13120
- }
13121
- if (seamRefusal === 'destination') {
13122
- 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);
13123
13593
  return;
13124
13594
  }
13125
13595
  const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
@@ -13206,6 +13676,191 @@
13206
13676
  // contiguous slice of the document (listRunOf()'s own contract), so the
13207
13677
  // members occupy CONSECUTIVE slots of `run` and "move them" is one call to
13208
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
+
13209
13864
  async function performListItemDrop(liEls, destEl) {
13210
13865
  const liEl = liEls[0];
13211
13866
  const lastLiEl = liEls[liEls.length - 1];
@@ -13217,7 +13872,7 @@
13217
13872
  const run = listRunOf(liEl);
13218
13873
  const srcIdxs = liEls.map((el) => run.indexOf(el));
13219
13874
  const srcIdx = srcIdxs[0];
13220
- if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13875
+ if (srcIdx < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13221
13876
  // A member outside the grip's own commit span means the set straddles TWO
13222
13877
  // runs — contiguity in `blocks` does not imply one run, because two
13223
13878
  // adjacent runs separated by a delimiter change ('- a' / '1. b') are
@@ -13225,7 +13880,8 @@
13225
13880
  // convertBlockViaMenu() gives, through the same message: the commit
13226
13881
  // re-serializes ONE run's line range and a member outside it would be
13227
13882
  // rewritten by a range that does not cover it.
13228
- 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; }
13229
13885
  // ...and they must be CONSECUTIVE slots of it. Guaranteed today by
13230
13886
  // spanIsContiguous() plus listRunOf() returning a contiguous slice, and
13231
13887
  // checked anyway rather than asserted in prose: reorderSpanRange() takes a
@@ -13294,7 +13950,9 @@
13294
13950
  // {mode:'append'} target when a block follows the run. Task 5 gave them
13295
13951
  // their own message — the remedy is "stay inside your own list", which
13296
13952
  // neither seam message says.
13297
- 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; }
13298
13956
  // `null` is the order-is-unchanged answer — the two home positions above
13299
13957
  // reach this by another road (an item whose next block is a sibling), and
13300
13958
  // it stays SILENT for the same reason.
@@ -13305,7 +13963,7 @@
13305
13963
  // the ARRAY, and the reordered span's first member is not the run's first
13306
13964
  // line.
13307
13965
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
13308
- if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13966
+ if (!range) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13309
13967
  const span = order.map((i) => run[i]);
13310
13968
  // ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
13311
13969
  //
@@ -13357,7 +14015,9 @@
13357
14015
  indent: clamped.has(el) ? clamped.get(el)
13358
14016
  : (Number(el.getAttribute('data-indent')) || 0),
13359
14017
  })))) {
13360
- 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);
13361
14021
  return;
13362
14022
  }
13363
14023
  // S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
@@ -13437,7 +14097,7 @@
13437
14097
  function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
13438
14098
  const ids = liEls.map((el) => el.getAttribute('data-block-id'));
13439
14099
  if (ids.indexOf(null) !== -1) return null;
13440
- const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
14100
+ const { lineMeta } = serializeListEls(span, { carryOver: carryOver || null });
13441
14101
  let first = -1;
13442
14102
  let last = -1;
13443
14103
  lineMeta.forEach((m, i) => {
@@ -13662,17 +14322,10 @@
13662
14322
  }
13663
14323
  // A plain press-release with no drag threshold crossed: open the menu
13664
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()).
13665
14327
  if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
13666
- else if (st.hit.isHeader) {
13667
- // The row menu's only item is "delete row", and the header row can
13668
- // never be deleted — showing it would just be an empty box. A plain
13669
- // click on the header grip highlights the row instead.
13670
- // clearEdgeHighlight() runs first so re-clicking a different header
13671
- // (or a different row's menu having been open) doesn't stack
13672
- // highlights within the same session.
13673
- clearEdgeHighlight();
13674
- highlightRow(st.hit.rowEl);
13675
- } else showRowMenu(st.hit.tableEl, st.hit.rowEl);
14328
+ else showRowMenu(st.hit.tableEl, st.hit.rowEl);
13676
14329
  });
13677
14330
 
13678
14331
  // Review fix (Critical): the browser/OS can ABORT a gesture outright —
@@ -14319,6 +14972,11 @@
14319
14972
  // paintSelToolbarMarks() below. Filled by buildSelToolbar(), which runs once.
14320
14973
  const selToolbarMarkBtns = [];
14321
14974
 
14975
+ const SELTB_ICON = {
14976
+ 'ed-seltb-b': 'bold', 'ed-seltb-i': 'italic', 'ed-seltb-s': 'strikethrough',
14977
+ 'ed-seltb-u': 'underline', 'ed-seltb-code': 'code', 'ed-seltb-link': 'link',
14978
+ };
14979
+
14322
14980
  function buildSelToolbar() {
14323
14981
  const el = document.createElement('div');
14324
14982
  el.className = 'ed-seltb';
@@ -14326,7 +14984,7 @@
14326
14984
  const b = document.createElement('button');
14327
14985
  b.type = 'button';
14328
14986
  b.className = 'ed-seltb-btn ' + cls;
14329
- b.textContent = label;
14987
+ b.innerHTML = icons.svg(SELTB_ICON[cls], 16);
14330
14988
  b.setAttribute('aria-label', ariaLabel);
14331
14989
  b.setAttribute('aria-pressed', 'false');
14332
14990
  selToolbarMarkBtns.push({ btn: b, tag: tag });
@@ -14344,6 +15002,10 @@
14344
15002
  addBtn('ed-seltb-s', 'S', '刪除線', 'DEL', () => applyMarkToggle('DEL'));
14345
15003
  addBtn('ed-seltb-u', 'U', '底線', 'U', () => applyMarkToggle('U'));
14346
15004
  addBtn('ed-seltb-code', '<>', 'Code', 'CODE', () => applyMarkToggle('CODE'));
15005
+ const sep = document.createElement('span');
15006
+ sep.className = 'ed-seltb-sep';
15007
+ sep.setAttribute('aria-hidden', 'true');
15008
+ el.appendChild(sep);
14347
15009
  addBtn('ed-seltb-link', '\u{1F517}', 'Link', 'A', () => applyLinkToggle());
14348
15010
  return el;
14349
15011
  }
@@ -14713,7 +15375,24 @@
14713
15375
  }
14714
15376
 
14715
15377
  // ── save ───────────────────────────────────────────────────────────────
15378
+ // v3.10.0: the outside-edit check (onDiskMtime() below) must not read its
15379
+ // own saves as someone else's edit. A ping answered while a save is in
15380
+ // flight, or one sent before a save that has since landed, carries an mtime
15381
+ // that is not comparable with `mtimeMs`; saveGen / savesInFlight let the
15382
+ // ping tell (no save in flight when it was sent, none started or in flight
15383
+ // when it came back). Reasoned from the ordering, not measured — both directions: a
15384
+ // stale pre-save mtime
15385
+ // against the post-save baseline would reload a clean page for nothing, and
15386
+ // a post-write mtime read before the save's reply lands would raise the
15387
+ // conflict card over the user's own save.
15388
+ let saveGen = 0;
15389
+ let savesInFlight = 0;
14716
15390
  async function save() {
15391
+ saveGen++;
15392
+ savesInFlight++;
15393
+ try { return await sendSave(); } finally { savesInFlight--; }
15394
+ }
15395
+ async function sendSave() {
14717
15396
  // fix 3: the receipt for the bytes about to leave, taken BEFORE the request
14718
15397
  // and not after it. `markSaved()` used to read the stack depth when the
14719
15398
  // RESPONSE landed, and a save round trip is hundreds of milliseconds —
@@ -14726,6 +15405,7 @@
14726
15405
  // See UndoStack.saveToken()/markSaved() for why the receipt carries an op
14727
15406
  // identity and not just a depth.
14728
15407
  const token = stack.saveToken();
15408
+ paintSaveStatus('saving');
14729
15409
  //
14730
15410
  // ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
14731
15411
  // that is a correction of something this file briefly carried. The premise
@@ -14754,8 +15434,9 @@
14754
15434
  body: JSON.stringify({ fileId: ED.fileId, content: lines.join(EOL), baseMtimeMs: mtimeMs }),
14755
15435
  });
14756
15436
  } catch (e) {
14757
- showBanner('Save failed — network error (' + describeFailure(e) +
14758
- '); changes NOT saved.', null, null);
15437
+ paintSaveStatus('failed');
15438
+ showError('無法儲存:連不到編輯伺服器(' + describeFailure(e) +
15439
+ ')。變更還在這個分頁裡,請稍後再按 ' + SAVE_KEY_HINT + '。', null, null);
14759
15440
  return;
14760
15441
  }
14761
15442
  if (res.status === 200) {
@@ -14763,7 +15444,8 @@
14763
15444
  try {
14764
15445
  j = await res.json();
14765
15446
  } catch (e) {
14766
- showBanner('Save failed — malformed server response; changes NOT saved.', null, null);
15447
+ paintSaveStatus('failed');
15448
+ showError('無法儲存:伺服器回傳的內容無法解析。變更還在這個分頁裡。', null, null);
14767
15449
  return;
14768
15450
  }
14769
15451
  mtimeMs = j.mtimeMs;
@@ -14781,6 +15463,7 @@
14781
15463
  return;
14782
15464
  }
14783
15465
  if (res.status === 409) {
15466
+ paintSaveStatus('failed');
14784
15467
  showConflictBanner();
14785
15468
  return;
14786
15469
  }
@@ -14788,7 +15471,8 @@
14788
15471
  // user's edits. Dirty state (and `mtimeMs`) is left untouched, and
14789
15472
  // there is no auto-retry; the user decides what to do next.
14790
15473
  const reason = await describeHttpFailure(res);
14791
- showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
15474
+ paintSaveStatus('failed');
15475
+ showError('無法儲存:' + reason + '。變更還在這個分頁裡。', null, null);
14792
15476
  }
14793
15477
 
14794
15478
  // v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
@@ -15067,12 +15751,6 @@
15067
15751
  let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
15068
15752
  let toolbarBlockEl = null;
15069
15753
  let toolbarStateSig = '';
15070
- // 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
15071
- // state ('preview'); docsource.js's MODES comment records why.
15072
- // docsource.js owns the cycle order and the lines[]<->string conversion.
15073
- let docMode = 'edit';
15074
- let sourceTextarea = null;
15075
- let sourceBaseLines = null;
15076
15754
  // 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
15077
15755
  // event. A ClipboardEvent carries no modifier state of its own, so the
15078
15756
  // intent has to be captured on the keystroke that produced it.
@@ -15109,9 +15787,10 @@
15109
15787
  // stray-key hand-back raises this rather than only some of them.
15110
15788
  const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
15111
15789
 
15112
- // Extra visual classes for the three inline buttons whose own glyph is the
15113
- // affordance (B is bold, I is italic, S is struck through) — the same
15114
- // idiom .ed-seltb-b/-i/-s use.
15790
+ // Extra classes on the three inline buttons. Until v3.10.0 their text glyph
15791
+ // was the affordance (B bold, I italic, S struck) and these classes styled
15792
+ // it; the buttons draw Lucide icons now and the classes carry no CSS, but
15793
+ // they stay as stable hooks, same as .ed-seltb-b/-i/-s.
15115
15794
  const TOOLBAR_BTN_CLASS = {
15116
15795
  bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
15117
15796
  };
@@ -15286,7 +15965,6 @@
15286
15965
  // measures it here because toolbar-model.js may not touch the DOM, and
15287
15966
  // .ed-seltb is painted from the same measurement — see updateToolbar().
15288
15967
  marks: selectionMarkStates(sel),
15289
- mode: docMode,
15290
15968
  // v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
15291
15969
  // above burstHasUncommittedEdit()'s own comment) is the SAME
15292
15970
  // expression setDirty() paints the title dot from — not a second copy
@@ -15329,9 +16007,9 @@
15329
16007
  btn.setAttribute('aria-pressed',
15330
16008
  st.mixed ? 'mixed' : (st.active ? 'true' : 'false'));
15331
16009
  }
15332
- // `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
15333
- // derived label; every other button keeps the glyph it was built with.
15334
- if (typeof st.label === 'string') btn.textContent = st.label;
16010
+ // v3.10.0: every button draws the fixed Lucide icon buildToolbar() gave
16011
+ // it. A derived label (H1..H6 on `headings`) no longer reaches the
16012
+ // button's content; the title below still follows the state.
15335
16013
  // v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
15336
16014
  // button's title names the NEXT state, so it has to be rewritten every
15337
16015
  // time the mode changes. It rides the same diffed loop as everything
@@ -15457,10 +16135,11 @@
15457
16135
  // Steps over disabled buttons. The walk is bounded by the button count and
15458
16136
  // wraps, so a bar with nothing enabled would leave the index where it was
15459
16137
  // rather than spin — see the model's own guarantee that this cannot happen
15460
- // (deriveState() keeps `preview` out of every disabling branch, including
15461
- // the source-mode override; T11-2 later exempted `outline` from that
15462
- // override too, so the enabled set there is `['outline','preview']` — the
15463
- // guarantee holds more strongly than when this was written, not less).
16138
+ // (deriveState() keeps `outline` out of every disabling branch. Until
16139
+ // v3.10.0 the guarantee rested on the source-mode button `preview`; that
16140
+ // button is gone, and `outline` — enabled in all 264 ctx combinations a
16141
+ // sweep of blockType x inList x indent x dirty x hasSelection produces —
16142
+ // carries it now).
15464
16143
  function moveToolbarCursor(delta) {
15465
16144
  const btns = toolbarRovingBtns();
15466
16145
  if (!btns.length) return;
@@ -15533,29 +16212,13 @@
15533
16212
  // Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
15534
16213
  // not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
15535
16214
  // focusin comment is explicit that SOME toolbar activations (list,
15536
- // ordered-list, check, table, insert-before, insert-after, preview)
16215
+ // ordered-list, check, table, insert-before, insert-after)
15537
16216
  // deliberately land a focusin on an editable block WITHOUT ending keynav,
15538
16217
  // so a real block surface must not trip this bail.
15539
16218
  //
15540
- // ⚠ FIX ROUND 1 (self-review, caught before ship): `preview`'s own landing
15541
- // is `sourceTextarea` — a real `<textarea>`, not a `.ed-wys-armed` block —
15542
- // so the exemption above named it but the first cut of this function did
15543
- // not actually implement it, and the omission is load-bearing: source
15544
- // mode's model disables every button except outline/preview (this file's
15545
- // own "S/4.1 Source mode" comment — the cursor is meant to be walked with
15546
- // Left/Right/Home/End between those two, THEN activated), and MEASURED,
15547
- // that walk is exactly what this function's first cut broke — the very
15548
- // next arrow key after landing in the source textarea silently exited
15549
- // keynav instead of moving between the two enabled buttons, closing off
15550
- // the one route backlog #6's own still-open `preview` round-trip gap (see
15551
- // activateToolbarCursor()'s comment) names as "THE WAY OUT". `sourceTextarea`
15552
- // is therefore excluded by reference (not by tag/class — an ordinary
15553
- // `textarea.ed-raw`, e.g. backlog #6's own quote/code rescue, still trips
15554
- // this bail exactly as intended, since typing there IS the user's own
15555
- // text, not bar navigation).
15556
16219
  function toolbarKeynavBlockedByRealControl() {
15557
16220
  const ae = document.activeElement;
15558
- if (!ae || ae === sourceTextarea) return false;
16221
+ if (!ae) return false;
15559
16222
  return !!(ae.tagName && (ae.tagName === 'INPUT' ||
15560
16223
  ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
15561
16224
  }
@@ -15566,8 +16229,9 @@
15566
16229
  // WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
15567
16230
  // enabled row in a plain paragraph, pressing Enter on the cursor and then
15568
16231
  // typing to see whether the characters land anywhere — UPDATED for Task 9
15569
- // (backlog #6), which fixed the first two bullets below; only the third
15570
- // remains an open gap, tracked below rather than left unstated:
16232
+ // (backlog #6), which fixed both bullets below. (A third, the source-mode
16233
+ // button's return trip leaving the caret on BODY, was still open when
16234
+ // v3.10.0 removed source mode altogether.)
15571
16235
  //
15572
16236
  // * FIXED: quote, code and line used to end on BODY with typing going
15573
16237
  // nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
@@ -15579,35 +16243,9 @@
15579
16243
  // what dropped the caret). undoViaToolbar()/redoViaToolbar()/
15580
16244
  // imageViaToolbar() now wrap the same captureCaretForSave()/
15581
16245
  // restoreCaretForSave() pair saveViaToolbar() uses.
15582
- // * STILL OPEN: `preview` (cycleDocMode(), the document-wide edit↔source
15583
- // toggle) is UNCHANGED — going INTO source mode focuses the source
15584
- // textarea correctly, but the RETURN trip (source → edit) still leaves
15585
- // the caret on BODY, MEASURED unchanged by this task's own review.
15586
- // Deliberately left open rather than rushed: unlike the two bullets
15587
- // above, there is no established rescue to reuse here —
15588
- // captureCaretForSave()'s block-id + text-offset coordinate system has
15589
- // no meaning for a source-textarea cursor position (a raw character
15590
- // offset into the WHOLE document string, not one block's rendered
15591
- // text), so a correct fix needs its own coordinate mapping and its own
15592
- // tests, not a copy-paste of this task's other two fixes. Backlog #6's
15593
- // first two clauses no longer make this escape hatch NECESSARY the way
15594
- // they used to (quote/code/line now have their own direct rescue,
15595
- // rather than needing the preview round-trip as their only way out),
15596
- // which is why this is deferred rather than treated as still blocking.
15597
16246
  //
15598
16247
  // A mouse click on those same buttons does exactly the same thing, so none
15599
- // of this is something the keyboard route introduced — a mouse user can
15600
- // always climb out of the still-open `preview` case by clicking into any
15601
- // OTHER editable text on the page, where a keyboard user cannot, because Tab
15602
- // from BODY goes nowhere (this file's own §3.5 contract).
15603
- //
15604
- // THE WAY OUT for the still-open case, driven end to end from a stranded
15605
- // BODY: the entry chord still works there (the bar's own state survives,
15606
- // and its row is fully enabled), so walking to the mode button and pressing
15607
- // Enter puts the caret in the source textarea, where typing lands. Coming
15608
- // back the same way returns the document to edit mode but leaves the caret
15609
- // on BODY again — the hatch is somewhere to keep typing, not (yet) a way
15610
- // back to the WYSIWYG caret.
16248
+ // of this is something the keyboard route introduced.
15611
16249
  function activateToolbarCursor() {
15612
16250
  const btn = toolbarRovingBtns()[toolbarRovingIndex];
15613
16251
  if (!btn || btn.disabled) return;
@@ -15640,7 +16278,7 @@
15640
16278
  btn.type = 'button';
15641
16279
  btn.className = 'ed-toolbar-btn' +
15642
16280
  (TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
15643
- btn.textContent = def.icon;
16281
+ btn.innerHTML = icons.svg(def.icon, 18);
15644
16282
  btn.title = def.title;
15645
16283
  btn.setAttribute('aria-label', def.title);
15646
16284
  btn.setAttribute('data-ed-tb', def.id);
@@ -15693,9 +16331,8 @@
15693
16331
  }, true);
15694
16332
  toolbarBtns = btns;
15695
16333
  // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
15696
- // 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
15697
- // paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
15698
- // 切換時寫入當前模式名稱。
16334
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度。v3.10.0 起它顯示存檔狀態
16335
+ // (paintSaveStatus());原本寫的模式名稱隨原始碼模式一起移除。
15699
16336
  const statusEl = document.createElement('span');
15700
16337
  statusEl.className = 'ed-toolbar-status';
15701
16338
  // Final wave, M7: the only announcement a mode switch ever gets. Every
@@ -15756,7 +16393,6 @@
15756
16393
  // for the caret rescue.
15757
16394
  case 'image': return imageViaToolbar();
15758
16395
  case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
15759
- case 'preview': return cycleDocMode();
15760
16396
  default: return undefined;
15761
16397
  }
15762
16398
  }
@@ -15921,7 +16557,7 @@
15921
16557
  if (!document.body.contains(blockEl)) {
15922
16558
  liveBlockEl = reresolveBlockEl(identity) ||
15923
16559
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
15924
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
16560
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
15925
16561
  }
15926
16562
  const rec = blockRecOf(liveBlockEl);
15927
16563
  // A block that owns no source line has an inverted range, and there is
@@ -16076,14 +16712,14 @@
16076
16712
  // two literals and then parses the bytes. Keep the two rules the same
16077
16713
  // shape, and let the server's refusal be the one the user sees.
16078
16714
  if (!insertableFile(blob)) {
16079
- showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
16715
+ showNotice(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE);
16080
16716
  return null;
16081
16717
  }
16082
16718
  let data;
16083
16719
  try {
16084
16720
  data = await blobToBase64(blob);
16085
16721
  } catch (e) {
16086
- showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
16722
+ showError('圖片讀取失敗 — ' + describeFailure(e), null, null);
16087
16723
  return null;
16088
16724
  }
16089
16725
  let res;
@@ -16095,12 +16731,12 @@
16095
16731
  }),
16096
16732
  });
16097
16733
  } catch (e) {
16098
- showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
16734
+ showError('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
16099
16735
  return null;
16100
16736
  }
16101
16737
  if (!res.ok) {
16102
16738
  const reason = await describeHttpFailure(res);
16103
- showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
16739
+ showError('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
16104
16740
  return null;
16105
16741
  }
16106
16742
  let j;
@@ -16111,11 +16747,11 @@
16111
16747
  // that is not a draw.io diagram -- so the reason has to arrive.
16112
16748
  if (!res.ok) {
16113
16749
  const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
16114
- showBanner('檔案上傳失敗 — ' + why, null, null);
16750
+ showError('檔案上傳失敗 — ' + why, null, null);
16115
16751
  return null;
16116
16752
  }
16117
16753
  if (!j || typeof j.path !== 'string') {
16118
- showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
16754
+ showError('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
16119
16755
  return null;
16120
16756
  }
16121
16757
  return j.path;
@@ -16154,7 +16790,7 @@
16154
16790
  }
16155
16791
  if (!paths.length) return;
16156
16792
  const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
16157
- if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16793
+ if (!target) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16158
16794
  const newLines = [];
16159
16795
  paths.forEach((p, i) => {
16160
16796
  if (i > 0) newLines.push('');
@@ -16219,7 +16855,7 @@
16219
16855
  ? blockEl
16220
16856
  : (reresolveBlockEl(identity) ||
16221
16857
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
16222
- if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16858
+ if (!anchorEl) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16223
16859
  // Re-captured AFTER the commit: `identity` above holds the PRE-commit
16224
16860
  // fingerprint, which the commit we just awaited may itself have
16225
16861
  // invalidated. What has to survive the dialog is the anchor as it stands
@@ -16251,7 +16887,7 @@
16251
16887
  // very symptom being fixed.
16252
16888
  const live = document.body.contains(anchorEl)
16253
16889
  ? anchorEl : reresolveBlockEl(dialogIdentity);
16254
- if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16890
+ if (!live) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16255
16891
  insertImages(files, live);
16256
16892
  });
16257
16893
  input.click();
@@ -16279,31 +16915,11 @@
16279
16915
  }
16280
16916
 
16281
16917
  function wireImageDrop() {
16282
- // ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
16283
- // load-bearing rather than tidy-minded.
16284
- //
16285
- // In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
16286
- // every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
16287
- // ungated drop ran the whole upload plus an `insertBlockBelow()` commit
16288
- // behind the hidden content, against `lines`. The textarea the user is
16289
- // looking at still held the PRE-drop source, and `leaveSourceMode()` then
16290
- // commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
16291
- // `lines` — erasing the reference that was just inserted. MEASURED:
16292
- // '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
16293
- // leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
16294
- // assets/x.png orphaned on disk. No error, no visible effect, and a stray
16295
- // binary left in what is usually a git working tree.
16296
- //
16297
- // v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
16298
- // the only other mode today, but the gate's reason is "the content the
16299
- // user is looking at is not the content a drop would write into" — a
16300
- // future third mode inherits the refusal rather than the hole.
16301
16918
  document.addEventListener('dragover', (e) => {
16302
16919
  // Nothing is dropped into a document behind a modal. Returning WITHOUT
16303
16920
  // preventDefault() is what makes the browser refuse the drop, so the
16304
16921
  // gesture is declined visibly rather than swallowed.
16305
16922
  if (waveEditorIsModal()) return;
16306
- if (docMode !== 'edit') return;
16307
16923
  if (!dragCarriesFiles(e)) return;
16308
16924
  e.preventDefault();
16309
16925
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
@@ -16313,7 +16929,6 @@
16313
16929
  // took the document behind it from 4 blocks to 5 and put an `<img>` into
16314
16930
  // `.content`, with the overlay still up and saying nothing.
16315
16931
  if (waveEditorIsModal()) return;
16316
- if (docMode !== 'edit') return;
16317
16932
  if (!dragCarriesFiles(e)) return;
16318
16933
  const files = e.dataTransfer
16319
16934
  ? Array.prototype.slice.call(e.dataTransfer.files || [])
@@ -16433,94 +17048,26 @@
16433
17048
  }, true);
16434
17049
  }
16435
17050
 
16436
- // ── 追加 4: the whole-document source escape hatch ─────────────────────
16437
- // v3.2.1: applyPreviewEditability() lived here and was preview mode's ONLY
16438
- // effect. Preview is gone (docsource.js's MODES comment carries the
16439
- // measurement), and with it the last caller of this function.
16440
-
16441
- function enterSourceMode() {
16442
- // The pre-edit snapshot fromSource() diffs against. Omitting it makes
16443
- // `changed` unconditionally true (docsource.js's documented fail-safe),
16444
- // i.e. a needless write of byte-identical content on every exit.
16445
- sourceBaseLines = lines.slice();
16446
- const ta = document.createElement('textarea');
16447
- ta.className = 'ed-source';
16448
- ta.setAttribute('aria-label', '文件原始碼');
16449
- ta.value = docSourceLib.toSource(lines, EOL);
16450
- contentEl.hidden = true;
16451
- contentEl.parentNode.insertBefore(ta, contentEl);
16452
- sourceTextarea = ta;
16453
- ta.focus();
16454
- }
16455
-
16456
- // Returns false when the commit's render failed — the textarea is then left
16457
- // standing with the user's text intact (a failed round trip must never be
16458
- // the thing that discards a whole document's edit), and the mode does not
16459
- // advance.
16460
- async function leaveSourceMode() {
16461
- const ta = sourceTextarea;
16462
- if (!ta) return true;
16463
- const parsed = docSourceLib.fromSource(ta.value, EOL, sourceBaseLines);
16464
- if (parsed.changed) {
16465
- // 全量置換 through the SAME replaceLines() pipeline every other edit
16466
- // uses — one commitRangeEdit over the whole document, therefore one
16467
- // undo op, and no second writer of `lines`.
16468
- const result = commitRangeEdit({ lines, blocks, stack }, 1, lines.length,
16469
- parsed.lines.join('\n'));
16470
- if (result.op !== null) {
16471
- const prevLines = lines;
16472
- lines = result.lines;
16473
- setDirty();
16474
- const editRange = result.op
16475
- ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
16476
- endLine: Math.max(result.op.startLine - 1, result.op.endLine),
16477
- delta: result.op.after.length - result.op.before.length }
16478
- : undefined;
16479
- const ok = await safeRerenderAll(editRange);
16480
- if (!ok) {
16481
- lines = rollbackFailedRender({ lines, stack }, result, prevLines);
16482
- setDirty();
16483
- return false;
16484
- }
16485
- }
16486
- }
16487
- sourceTextarea = null;
16488
- sourceBaseLines = null;
16489
- ta.remove();
16490
- contentEl.hidden = false;
16491
- return true;
16492
- }
16493
-
16494
- async function cycleDocMode() {
16495
- if (!docSourceLib) return;
16496
- await setDocMode(docSourceLib.next(docMode));
16497
- }
16498
-
16499
- async function setDocMode(next) {
16500
- if (next === docMode) return;
16501
- if (docMode === 'edit') {
16502
- // Same precondition every structural operation has: resolve whatever
16503
- // session is open before the DOM it lives in is swapped or hidden.
16504
- const ok = await switchAwayFrom();
16505
- if (!ok) return;
16506
- }
16507
- if (docMode === 'source') {
16508
- const ok = await leaveSourceMode();
16509
- if (!ok) return;
16510
- }
16511
- docMode = next;
16512
- if (docMode === 'source') enterSourceMode();
16513
- document.body.setAttribute('data-ed-mode', docMode);
16514
- paintModeStatus();
16515
- updateToolbar();
16516
- }
16517
-
16518
- // v3.2.1: 模式必須說出來 —— 使用者按第二下以為卡住,其實是進了 source。
16519
- // Called from setDocMode() AND from mountToolbar(), so the slot is truthful
16520
- // from the first paint rather than only after the first mode change.
16521
- function paintModeStatus() {
17051
+ // The toolbar's right-hand status slot says whether the file on disk matches
17052
+ // the page. Save stays manual (Ctrl/Cmd+S or the save button); this only
17053
+ // makes the state visible. role="status" (mountToolbar()) so a screen
17054
+ // reader hears it. v3.10.0: it used to name the document mode, which went
17055
+ // with source mode. The save button's aria-pressed follows 'dirty' — save
17056
+ // is not in TOOLBAR_TOGGLE_IDS, so updateToolbar() never writes it back.
17057
+ const SAVE_KEY_HINT = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘S' : 'Ctrl+S';
17058
+ function paintSaveStatus(state) {
16522
17059
  const statusEl = document.querySelector('.ed-toolbar-status');
16523
- if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
17060
+ if (!statusEl) return;
17061
+ // setDirty() runs on every burst mutation; rewriting an unchanged role=
17062
+ // "status" region would have a screen reader re-announce it per keystroke.
17063
+ if (statusEl.getAttribute('data-state') === state) return;
17064
+ statusEl.setAttribute('data-state', state);
17065
+ if (state === 'clean') statusEl.innerHTML = icons.svg('check', 15) + '<span>已儲存</span>';
17066
+ else if (state === 'dirty') statusEl.innerHTML = '<i class="ed-status-dot"></i><span>有未儲存的變更 · ' + SAVE_KEY_HINT + ' 儲存</span>';
17067
+ else if (state === 'saving') statusEl.innerHTML = '<span>儲存中…</span>';
17068
+ else statusEl.innerHTML = icons.svg('circle-alert', 15) + '<span>無法儲存</span>';
17069
+ const btn = toolbarBtns && toolbarBtns.save;
17070
+ if (btn) btn.setAttribute('aria-pressed', state === 'dirty' ? 'true' : 'false');
16524
17071
  }
16525
17072
 
16526
17073
  // ── mount ──────────────────────────────────────────────────────────────
@@ -16600,8 +17147,7 @@
16600
17147
  // while both had to end up with the hint out.
16601
17148
  toolbarEl.addEventListener('scroll', paintToolbarOverflow, { passive: true });
16602
17149
  window.addEventListener('resize', paintToolbarOverflow);
16603
- document.body.setAttribute('data-ed-mode', docMode);
16604
- paintModeStatus();
17150
+ paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
16605
17151
  wireToolbarTracking();
16606
17152
  wireImageDrop();
16607
17153
  wirePlainPasteArming();
@@ -16620,10 +17166,7 @@
16620
17166
  // That bail returns for EVERY key, so an entry gesture placed under it
16621
17167
  // would be dead while a degraded block's raw editor holds focus — driven
16622
17168
  // from that editor, the gesture lands on the bar and the caret stays in
16623
- // the textarea. Source mode is a different surface (`textarea.ed-source`,
16624
- // which that bail does not match) and reaches the bar as well; there the
16625
- // model disables the row apart from the button that gets you back out, so
16626
- // the cursor lands on that one.
17169
+ // the textarea.
16627
17170
  //
16628
17171
  // Escape is NOT claimed here. It belongs to the ordered prologue just
16629
17172
  // below, which already ranks a drag and an open menu above everything
@@ -16789,7 +17332,7 @@
16789
17332
  e.key !== 'Control' && e.key !== 'Meta') {
16790
17333
  exitToolbarKeynav();
16791
17334
  if (e.key !== 'Tab') {
16792
- showBanner(TOOLBAR_KEYNAV_EXIT_MESSAGE, null, null);
17335
+ showNotice(TOOLBAR_KEYNAV_EXIT_MESSAGE);
16793
17336
  activeBannerIsKeynavExit = true;
16794
17337
  }
16795
17338
  }
@@ -17746,7 +18289,7 @@
17746
18289
  return;
17747
18290
  }
17748
18291
  drawioDeferredNotice = null;
17749
- showBanner(message, null, null);
18292
+ showNotice(message);
17750
18293
  }
17751
18294
  function flushDrawioNotice() {
17752
18295
  if (!drawioDeferredNotice) return;
@@ -17757,7 +18300,7 @@
17757
18300
  if (activeBanner || noticeTeardownWouldDisrupt()) return;
17758
18301
  const m = drawioDeferredNotice.message;
17759
18302
  drawioDeferredNotice = null;
17760
- showBanner(m, null, null);
18303
+ showNotice(m);
17761
18304
  }
17762
18305
  const DRAWIO_PAGE_GONE_MSG =
17763
18306
  '目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
@@ -18071,6 +18614,45 @@
18071
18614
  }
18072
18615
  }
18073
18616
 
18617
+ // v3.10.0: an edit made outside the browser. Clean page: reload it and land
18618
+ // on the same heading. Unsaved work here: say so now, once per outside
18619
+ // version, instead of waiting for the save to bounce with a 409.
18620
+ // documentIsDirty() already counts an uncommitted burst, so typing that has
18621
+ // not been committed yet takes the conflict branch (Review Focus 4).
18622
+ let conflictShownFor = 0;
18623
+ function onDiskMtime(disk) {
18624
+ if (disk === mtimeMs || waveEditorIsModal()) return;
18625
+ // An open MD 原始碼 box with typed-but-uncommitted text is unsaved work
18626
+ // too, and documentIsDirty() cannot see it (a textarea's value touches no
18627
+ // block's innerHTML). A reload would silently throw it away.
18628
+ const rawPending = !!(activeEditor && activeEditor.hasChanges());
18629
+ if (!documentIsDirty() && !rawPending) {
18630
+ try { sessionStorage.setItem('md2doc-reload-anchor', JSON.stringify(readingAnchor())); } catch (e) { /* private mode */ }
18631
+ location.reload();
18632
+ return;
18633
+ }
18634
+ if (conflictShownFor === disk) return;
18635
+ conflictShownFor = disk;
18636
+ showConflictBanner();
18637
+ }
18638
+ function readingAnchor() {
18639
+ const hs = [].slice.call(document.querySelectorAll('.content h1[id], .content h2[id], .content h3[id], .content h4[id]'));
18640
+ let best = null;
18641
+ for (const h of hs) { if (h.getBoundingClientRect().top <= 80) best = h; else break; }
18642
+ return best ? { id: best.id, off: Math.round(best.getBoundingClientRect().top) } : { y: Math.round(scrollY) };
18643
+ }
18644
+ (function restoreReloadAnchor() {
18645
+ let a = null;
18646
+ try { a = JSON.parse(sessionStorage.getItem('md2doc-reload-anchor') || 'null'); sessionStorage.removeItem('md2doc-reload-anchor'); } catch (e) { a = null; }
18647
+ if (!a) return;
18648
+ requestAnimationFrame(() => {
18649
+ const h = a.id && document.getElementById(a.id);
18650
+ if (h) scrollTo(0, scrollY + h.getBoundingClientRect().top - a.off);
18651
+ else if (typeof a.y === 'number') scrollTo(0, a.y);
18652
+ showNotice('檔案在別處被修改,已重新載入。');
18653
+ });
18654
+ })();
18655
+
18074
18656
  setInterval(() => {
18075
18657
  // A notice this refresh could not show earlier because the slot was
18076
18658
  // taken (see noteDrawioNotice) gets its next chance here, on the same
@@ -18095,10 +18677,17 @@
18095
18677
  // is the accepted worst-case latency for this whole feature.
18096
18678
  const ack = drawioAckPending;
18097
18679
  drawioAckPending = null;
18680
+ const pingSaveGen = saveGen;
18681
+ const pingSavesInFlight = savesInFlight;
18098
18682
  fetch('/api/ping', {
18099
18683
  method: 'POST', headers: { 'content-type': 'application/json' },
18100
18684
  body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
18101
18685
  }).then((res) => {
18686
+ // v3.10.0: X-Md2doc-Mtime is the file's mtime as the server saw it. Only
18687
+ // comparable when no save started or ran during this round trip.
18688
+ const disk = Number(res.headers.get('x-md2doc-mtime'));
18689
+ if (isFinite(disk) && disk > 0 && saveGen === pingSaveGen &&
18690
+ pingSavesInFlight === 0 && savesInFlight === 0) onDiskMtime(disk);
18102
18691
  if (res.status !== 200) return null;
18103
18692
  return res.json().catch(() => null);
18104
18693
  }).then((j) => {
@@ -18748,7 +19337,7 @@
18748
19337
  // missing from the file after the user has read about it, and the dirty
18749
19338
  // net is about the work, not about the notice.
18750
19339
  let el = null;
18751
- el = showBanner(
19340
+ el = showError(
18752
19341
  '這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
18753
19342
  '比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
18754
19343
  '(不含前後兩道圍欄)。原因:' + reason,
@@ -18866,7 +19455,7 @@
18866
19455
  // Kept, with a hole in it. The user is told once more on the way out,
18867
19456
  // because the status line they were reading has just been removed from
18868
19457
  // the screen along with the rest of the dialog.
18869
- showBanner(
19458
+ showError(
18870
19459
  '這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
18871
19460
  '寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
18872
19461
  seam.endLine + ' 行)。',
@@ -18904,7 +19493,7 @@
18904
19493
  // it. Losing a session is bad; silently corrupting the document the user
18905
19494
  // keeps editing afterwards is worse, and before fix 1 this path risked
18906
19495
  // both at once.
18907
- showBanner(
19496
+ showError(
18908
19497
  '畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
18909
19498
  'Ctrl+Z 可以撤銷)。', null, null);
18910
19499
  }