@helping-ai-workflow/md2doc 3.9.0 → 3.10.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.
@@ -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) {
@@ -4237,6 +4263,38 @@
4237
4263
  blockEl.appendChild(buildGutterInsertButton());
4238
4264
  blockEl.appendChild(buildGutterHandle());
4239
4265
  });
4266
+ alignGutterTops(blockEls);
4267
+ }
4268
+
4269
+ // The ⠿ / + pair sits on the block's first line of text, whatever the
4270
+ // block's own margin and line-height (a heading line is 49.5px tall, a
4271
+ // paragraph's 24.75px). Measured from the first real text node's first
4272
+ // line box, so tables, list items and code all land on their first row; a
4273
+ // block with no text (an image, a bare diagram) keeps top 0. armEditables()
4274
+ // also runs on a detached <template> fragment (the partial re-render path),
4275
+ // where nothing has a layout yet: then it waits one frame and measures the
4276
+ // blocks that have been connected by then.
4277
+ function alignGutterTops(blockEls) {
4278
+ if (!blockEls.length || !blockEls[0].isConnected) {
4279
+ if (blockEls.length) requestAnimationFrame(() => alignGutterTops(blockEls.filter((b) => b.isConnected)));
4280
+ return;
4281
+ }
4282
+ for (const b of blockEls) {
4283
+ const walker = document.createTreeWalker(b, NodeFilter.SHOW_TEXT, {
4284
+ acceptNode: (n) => (n.data.trim() && !n.parentElement.closest('.ed-handle, .ed-insert, .ed-li-marker, svg'))
4285
+ ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
4286
+ });
4287
+ const t = walker.nextNode();
4288
+ let top = 0;
4289
+ if (t) {
4290
+ const rg = document.createRange();
4291
+ rg.selectNodeContents(t);
4292
+ const line = rg.getClientRects()[0];
4293
+ const handle = b.querySelector('.ed-handle');
4294
+ if (line && handle) top = Math.round(line.top + line.height / 2 - b.getBoundingClientRect().top - handle.offsetHeight / 2);
4295
+ }
4296
+ b.style.setProperty('--ed-pair-top', top + 'px');
4297
+ }
4240
4298
  }
4241
4299
 
4242
4300
  // A fresh ⠿ button per block — deliberately NOT wired with its own
@@ -4248,7 +4306,7 @@
4248
4306
  const el = document.createElement('button');
4249
4307
  el.type = 'button';
4250
4308
  el.className = 'ed-handle';
4251
- el.textContent = '⠿';
4309
+ el.innerHTML = icons.svg('grip-vertical', 16);
4252
4310
  el.setAttribute('aria-label', '區塊選項');
4253
4311
  // v2.11.1: a <button> is a sequential focus stop, and there is one of
4254
4312
  // these plus one + standing immediately after EVERY block — so any Tab
@@ -4703,7 +4761,7 @@
4703
4761
  const el = document.createElement('button');
4704
4762
  el.type = 'button';
4705
4763
  el.className = 'ed-insert';
4706
- el.textContent = '+';
4764
+ el.innerHTML = icons.svg('plus', 16);
4707
4765
  el.setAttribute('aria-label', '插入區塊');
4708
4766
  // Not a tab stop — see buildGutterHandle() above for the whole reason.
4709
4767
  el.setAttribute('tabindex', '-1');
@@ -4963,7 +5021,7 @@
4963
5021
  if (!document.body.contains(blockEl)) {
4964
5022
  liveBlockEl = reresolveBlockEl(identity) ||
4965
5023
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
4966
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
5024
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
4967
5025
  }
4968
5026
  // T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
4969
5027
  // for the same reason and against the same LIVE block. A block that owns
@@ -5292,14 +5350,14 @@
5292
5350
  if (!document.body.contains(blockEl)) {
5293
5351
  liveBlockEl = reresolveBlockEl(identity) ||
5294
5352
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
5295
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5353
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5296
5354
  }
5297
5355
  // A block that owns no source line has an INVERTED range
5298
5356
  // (endLine === startLine - 1) and every commit helper handed one does
5299
5357
  // something plausible and wrong.
5300
5358
  if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
5301
5359
  const rec = blockRecOf(liveBlockEl);
5302
- if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5360
+ if (!rec) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5303
5361
  if (anchor) anchor.line = rec.startLine;
5304
5362
 
5305
5363
  const res = selectionLib
@@ -5336,7 +5394,7 @@
5336
5394
  const els = [];
5337
5395
  for (let i = 0; i < members.length; i++) {
5338
5396
  const el = blockElById(members[i].id);
5339
- if (!el) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5397
+ if (!el) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
5340
5398
  // Belt to membersOf()'s own braces: selection.js excludes a block that
5341
5399
  // owns no line from every member set it builds, so this is unreachable —
5342
5400
  // but "another module filters it" is an argument about another file's
@@ -7106,7 +7164,7 @@
7106
7164
  // not before: the user can reach exactly that through the ⠿ menu.
7107
7165
  const editedIdStr = String(burst.blockId);
7108
7166
  if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
7109
- showBanner('含不支援的格式,改用原始碼編輯', null, null);
7167
+ showNotice('含不支援的格式,改用原始碼編輯');
7110
7168
  endBurstWithoutResolve();
7111
7169
  await safeRerenderAll();
7112
7170
  return false;
@@ -7222,7 +7280,7 @@
7222
7280
  // `lines`), and return false so the caller (switchAwayFrom(), on
7223
7281
  // behalf of whatever triggered this resolution) aborts instead of
7224
7282
  // proceeding as if the burst resolved cleanly.
7225
- showBanner('含不支援的格式,改用原始碼編輯', null, null);
7283
+ showNotice('含不支援的格式,改用原始碼編輯');
7226
7284
  endBurstWithoutResolve();
7227
7285
  openRawEditor(burst.blockEl);
7228
7286
  return false;
@@ -7543,6 +7601,10 @@
7543
7601
  }));
7544
7602
  };
7545
7603
  window.__edTestForceRerender = function () { return rerenderAll(); };
7604
+ // v3.10.0: a notice has no close button (it fades after 4s). Tests that only
7605
+ // need the banner gone before their next gesture take it down through the
7606
+ // same dismissBannerEl() the fade timer and the ✕ use.
7607
+ window.__edTestDismissBanner = function () { if (activeBanner) dismissBannerEl(activeBanner); };
7546
7608
  window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
7547
7609
 
7548
7610
  // v3.0.2: computeIndentClamp() lives past this file's node early-exit
@@ -9222,7 +9284,7 @@
9222
9284
  // and shadowed each other silently (last-declaration-wins), so the
9223
9285
  // no-source-line callers pass their own text instead of a second function.
9224
9286
  function refuseStructuralListEdit(message) {
9225
- showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
9287
+ showNotice(message || '此清單含不支援的格式,無法調整結構');
9226
9288
  // Review recommendation 5: mark it, so the next SUCCESSFUL render clears
9227
9289
  // it (dismissRefusalBanner(), called from rerenderAll()). Set after the
9228
9290
  // showBanner() call, which resets the flag for every other caller.
@@ -10310,7 +10372,7 @@
10310
10372
  const burst = currentBurst;
10311
10373
  if (!burst || burst.blockType !== 'table' || burst.editEl !== tableEl || !grid.length) return false;
10312
10374
  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; }
10375
+ if (grid.length > 500 || width > 50) { showNotice(GRID_PASTE_TOO_BIG_MESSAGE); return true; }
10314
10376
  const origin = burst.cellRange
10315
10377
  ? (() => { const rc = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10316
10378
  return { r: rc.top, c: rc.left }; })()
@@ -10423,7 +10485,7 @@
10423
10485
  const liveSrc = srcRec
10424
10486
  ? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
10425
10487
  dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
10426
- if (!dest) { showBanner(DROPPED_GESTURE_MESSAGE); return; }
10488
+ if (!dest) { showError(DROPPED_GESTURE_MESSAGE); return; }
10427
10489
  }
10428
10490
  if (x !== null) arrowGoalX = x;
10429
10491
  arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
@@ -10991,7 +11053,7 @@
10991
11053
  // Dropping is still the right answer (see the S1 comment above); saying
10992
11054
  // nothing was not.
10993
11055
  if (!liveTableEl) {
10994
- showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
11056
+ showError(DROPPED_GESTURE_MESSAGE, null, null); return null;
10995
11057
  }
10996
11058
  }
10997
11059
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
@@ -11059,7 +11121,7 @@
11059
11121
  // 15 call sites): can't relocate it on the live table -> drop the
11060
11122
  // gesture and say so, never guess.
11061
11123
  if (!targetCell) {
11062
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
11124
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
11063
11125
  return;
11064
11126
  }
11065
11127
  currentBurst.history.snap('insert-col-pre');
@@ -11111,7 +11173,7 @@
11111
11173
  // can't relocate it on the live table -> drop the gesture and say so,
11112
11174
  // never guess.
11113
11175
  if (afterRowIndex >= 0 && !afterRow) {
11114
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
11176
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
11115
11177
  return;
11116
11178
  }
11117
11179
  currentBurst.history.snap('insert-row-pre');
@@ -11587,7 +11649,7 @@
11587
11649
  if (!liveTableEl) return;
11588
11650
  const headerRow = headerRowOf(liveTableEl);
11589
11651
  if (!headerRow || headerRow.cells.length <= 1) {
11590
- showBanner('無法刪除最後一欄', null, null);
11652
+ showNotice('無法刪除最後一欄');
11591
11653
  return;
11592
11654
  }
11593
11655
  currentBurst.history.snap('delete-col-pre');
@@ -11615,11 +11677,11 @@
11615
11677
  const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
11616
11678
  if (!liveRowEl) return;
11617
11679
  if (liveRowEl === headerRowOf(liveTableEl)) {
11618
- showBanner('無法刪除標題列', null, null);
11680
+ showNotice('無法刪除標題列');
11619
11681
  return;
11620
11682
  }
11621
11683
  if (bodyRowsOf(liveTableEl).length <= 1) {
11622
- showBanner('無法刪除最後一列', null, null);
11684
+ showNotice('無法刪除最後一列');
11623
11685
  return;
11624
11686
  }
11625
11687
  currentBurst.history.snap('delete-row-pre');
@@ -12936,7 +12998,7 @@
12936
12998
  const sourceEl = els[st.sourceIndex];
12937
12999
  const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
12938
13000
  if (!sourceEl || (target.mode !== 'append' && !destEl)) {
12939
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
13001
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
12940
13002
  return;
12941
13003
  }
12942
13004
  const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
@@ -12970,7 +13032,7 @@
12970
13032
  if (destEl && !document.body.contains(destEl)) {
12971
13033
  liveDestEl = reresolveBlockEl(destIdentity) ||
12972
13034
  (destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
12973
- if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13035
+ if (!liveDestEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
12974
13036
  }
12975
13037
  // ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
12976
13038
  // §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
@@ -13039,7 +13101,7 @@
13039
13101
  const sj = live.indexOf(opEls[opEls.length - 1]);
13040
13102
  const rest = live.filter((el) => opEls.indexOf(el) === -1);
13041
13103
  const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
13042
- if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13104
+ if (si < 0 || sj < 0 || di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13043
13105
  // §3.3's set is contiguous in block order — resolveGutterOperands()'s own
13044
13106
  // gate — so its members' lines plus the separators between them are ONE
13045
13107
  // range, which is what makes a batch move the same single commitRangeEdit
@@ -13047,7 +13109,7 @@
13047
13109
  const srcRec = spanMoveRange(opRecs);
13048
13110
  const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
13049
13111
  if (!srcRec || (liveDestEl && !destRec)) {
13050
- showBanner(DROPPED_GESTURE_MESSAGE, null, null);
13112
+ showError(DROPPED_GESTURE_MESSAGE, null, null);
13051
13113
  return;
13052
13114
  }
13053
13115
  // A destination that owns no source line has an INVERTED range, and its
@@ -13217,7 +13279,7 @@
13217
13279
  const run = listRunOf(liEl);
13218
13280
  const srcIdxs = liEls.map((el) => run.indexOf(el));
13219
13281
  const srcIdx = srcIdxs[0];
13220
- if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13282
+ if (srcIdx < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13221
13283
  // A member outside the grip's own commit span means the set straddles TWO
13222
13284
  // runs — contiguity in `blocks` does not imply one run, because two
13223
13285
  // adjacent runs separated by a delimiter change ('- a' / '1. b') are
@@ -13305,7 +13367,7 @@
13305
13367
  // the ARRAY, and the reordered span's first member is not the run's first
13306
13368
  // line.
13307
13369
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
13308
- if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
13370
+ if (!range) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
13309
13371
  const span = order.map((i) => run[i]);
13310
13372
  // ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
13311
13373
  //
@@ -14319,6 +14381,11 @@
14319
14381
  // paintSelToolbarMarks() below. Filled by buildSelToolbar(), which runs once.
14320
14382
  const selToolbarMarkBtns = [];
14321
14383
 
14384
+ const SELTB_ICON = {
14385
+ 'ed-seltb-b': 'bold', 'ed-seltb-i': 'italic', 'ed-seltb-s': 'strikethrough',
14386
+ 'ed-seltb-u': 'underline', 'ed-seltb-code': 'code', 'ed-seltb-link': 'link',
14387
+ };
14388
+
14322
14389
  function buildSelToolbar() {
14323
14390
  const el = document.createElement('div');
14324
14391
  el.className = 'ed-seltb';
@@ -14326,7 +14393,7 @@
14326
14393
  const b = document.createElement('button');
14327
14394
  b.type = 'button';
14328
14395
  b.className = 'ed-seltb-btn ' + cls;
14329
- b.textContent = label;
14396
+ b.innerHTML = icons.svg(SELTB_ICON[cls], 16);
14330
14397
  b.setAttribute('aria-label', ariaLabel);
14331
14398
  b.setAttribute('aria-pressed', 'false');
14332
14399
  selToolbarMarkBtns.push({ btn: b, tag: tag });
@@ -14344,6 +14411,10 @@
14344
14411
  addBtn('ed-seltb-s', 'S', '刪除線', 'DEL', () => applyMarkToggle('DEL'));
14345
14412
  addBtn('ed-seltb-u', 'U', '底線', 'U', () => applyMarkToggle('U'));
14346
14413
  addBtn('ed-seltb-code', '<>', 'Code', 'CODE', () => applyMarkToggle('CODE'));
14414
+ const sep = document.createElement('span');
14415
+ sep.className = 'ed-seltb-sep';
14416
+ sep.setAttribute('aria-hidden', 'true');
14417
+ el.appendChild(sep);
14347
14418
  addBtn('ed-seltb-link', '\u{1F517}', 'Link', 'A', () => applyLinkToggle());
14348
14419
  return el;
14349
14420
  }
@@ -14713,7 +14784,24 @@
14713
14784
  }
14714
14785
 
14715
14786
  // ── save ───────────────────────────────────────────────────────────────
14787
+ // v3.10.0: the outside-edit check (onDiskMtime() below) must not read its
14788
+ // own saves as someone else's edit. A ping answered while a save is in
14789
+ // flight, or one sent before a save that has since landed, carries an mtime
14790
+ // that is not comparable with `mtimeMs`; saveGen / savesInFlight let the
14791
+ // ping tell (no save in flight when it was sent, none started or in flight
14792
+ // when it came back). Reasoned from the ordering, not measured — both directions: a
14793
+ // stale pre-save mtime
14794
+ // against the post-save baseline would reload a clean page for nothing, and
14795
+ // a post-write mtime read before the save's reply lands would raise the
14796
+ // conflict card over the user's own save.
14797
+ let saveGen = 0;
14798
+ let savesInFlight = 0;
14716
14799
  async function save() {
14800
+ saveGen++;
14801
+ savesInFlight++;
14802
+ try { return await sendSave(); } finally { savesInFlight--; }
14803
+ }
14804
+ async function sendSave() {
14717
14805
  // fix 3: the receipt for the bytes about to leave, taken BEFORE the request
14718
14806
  // and not after it. `markSaved()` used to read the stack depth when the
14719
14807
  // RESPONSE landed, and a save round trip is hundreds of milliseconds —
@@ -14726,6 +14814,7 @@
14726
14814
  // See UndoStack.saveToken()/markSaved() for why the receipt carries an op
14727
14815
  // identity and not just a depth.
14728
14816
  const token = stack.saveToken();
14817
+ paintSaveStatus('saving');
14729
14818
  //
14730
14819
  // ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
14731
14820
  // that is a correction of something this file briefly carried. The premise
@@ -14754,8 +14843,9 @@
14754
14843
  body: JSON.stringify({ fileId: ED.fileId, content: lines.join(EOL), baseMtimeMs: mtimeMs }),
14755
14844
  });
14756
14845
  } catch (e) {
14757
- showBanner('Save failed — network error (' + describeFailure(e) +
14758
- '); changes NOT saved.', null, null);
14846
+ paintSaveStatus('failed');
14847
+ showError('無法儲存:連不到編輯伺服器(' + describeFailure(e) +
14848
+ ')。變更還在這個分頁裡,請稍後再按 ' + SAVE_KEY_HINT + '。', null, null);
14759
14849
  return;
14760
14850
  }
14761
14851
  if (res.status === 200) {
@@ -14763,7 +14853,8 @@
14763
14853
  try {
14764
14854
  j = await res.json();
14765
14855
  } catch (e) {
14766
- showBanner('Save failed — malformed server response; changes NOT saved.', null, null);
14856
+ paintSaveStatus('failed');
14857
+ showError('無法儲存:伺服器回傳的內容無法解析。變更還在這個分頁裡。', null, null);
14767
14858
  return;
14768
14859
  }
14769
14860
  mtimeMs = j.mtimeMs;
@@ -14781,6 +14872,7 @@
14781
14872
  return;
14782
14873
  }
14783
14874
  if (res.status === 409) {
14875
+ paintSaveStatus('failed');
14784
14876
  showConflictBanner();
14785
14877
  return;
14786
14878
  }
@@ -14788,7 +14880,8 @@
14788
14880
  // user's edits. Dirty state (and `mtimeMs`) is left untouched, and
14789
14881
  // there is no auto-retry; the user decides what to do next.
14790
14882
  const reason = await describeHttpFailure(res);
14791
- showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
14883
+ paintSaveStatus('failed');
14884
+ showError('無法儲存:' + reason + '。變更還在這個分頁裡。', null, null);
14792
14885
  }
14793
14886
 
14794
14887
  // v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
@@ -15067,12 +15160,6 @@
15067
15160
  let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
15068
15161
  let toolbarBlockEl = null;
15069
15162
  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
15163
  // 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
15077
15164
  // event. A ClipboardEvent carries no modifier state of its own, so the
15078
15165
  // intent has to be captured on the keystroke that produced it.
@@ -15109,9 +15196,10 @@
15109
15196
  // stray-key hand-back raises this rather than only some of them.
15110
15197
  const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
15111
15198
 
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.
15199
+ // Extra classes on the three inline buttons. Until v3.10.0 their text glyph
15200
+ // was the affordance (B bold, I italic, S struck) and these classes styled
15201
+ // it; the buttons draw Lucide icons now and the classes carry no CSS, but
15202
+ // they stay as stable hooks, same as .ed-seltb-b/-i/-s.
15115
15203
  const TOOLBAR_BTN_CLASS = {
15116
15204
  bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
15117
15205
  };
@@ -15286,7 +15374,6 @@
15286
15374
  // measures it here because toolbar-model.js may not touch the DOM, and
15287
15375
  // .ed-seltb is painted from the same measurement — see updateToolbar().
15288
15376
  marks: selectionMarkStates(sel),
15289
- mode: docMode,
15290
15377
  // v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
15291
15378
  // above burstHasUncommittedEdit()'s own comment) is the SAME
15292
15379
  // expression setDirty() paints the title dot from — not a second copy
@@ -15329,9 +15416,9 @@
15329
15416
  btn.setAttribute('aria-pressed',
15330
15417
  st.mixed ? 'mixed' : (st.active ? 'true' : 'false'));
15331
15418
  }
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;
15419
+ // v3.10.0: every button draws the fixed Lucide icon buildToolbar() gave
15420
+ // it. A derived label (H1..H6 on `headings`) no longer reaches the
15421
+ // button's content; the title below still follows the state.
15335
15422
  // v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
15336
15423
  // button's title names the NEXT state, so it has to be rewritten every
15337
15424
  // time the mode changes. It rides the same diffed loop as everything
@@ -15457,10 +15544,11 @@
15457
15544
  // Steps over disabled buttons. The walk is bounded by the button count and
15458
15545
  // wraps, so a bar with nothing enabled would leave the index where it was
15459
15546
  // 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).
15547
+ // (deriveState() keeps `outline` out of every disabling branch. Until
15548
+ // v3.10.0 the guarantee rested on the source-mode button `preview`; that
15549
+ // button is gone, and `outline` — enabled in all 264 ctx combinations a
15550
+ // sweep of blockType x inList x indent x dirty x hasSelection produces —
15551
+ // carries it now).
15464
15552
  function moveToolbarCursor(delta) {
15465
15553
  const btns = toolbarRovingBtns();
15466
15554
  if (!btns.length) return;
@@ -15533,29 +15621,13 @@
15533
15621
  // Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
15534
15622
  // not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
15535
15623
  // focusin comment is explicit that SOME toolbar activations (list,
15536
- // ordered-list, check, table, insert-before, insert-after, preview)
15624
+ // ordered-list, check, table, insert-before, insert-after)
15537
15625
  // deliberately land a focusin on an editable block WITHOUT ending keynav,
15538
15626
  // so a real block surface must not trip this bail.
15539
15627
  //
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
15628
  function toolbarKeynavBlockedByRealControl() {
15557
15629
  const ae = document.activeElement;
15558
- if (!ae || ae === sourceTextarea) return false;
15630
+ if (!ae) return false;
15559
15631
  return !!(ae.tagName && (ae.tagName === 'INPUT' ||
15560
15632
  ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
15561
15633
  }
@@ -15566,8 +15638,9 @@
15566
15638
  // WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
15567
15639
  // enabled row in a plain paragraph, pressing Enter on the cursor and then
15568
15640
  // 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:
15641
+ // (backlog #6), which fixed both bullets below. (A third, the source-mode
15642
+ // button's return trip leaving the caret on BODY, was still open when
15643
+ // v3.10.0 removed source mode altogether.)
15571
15644
  //
15572
15645
  // * FIXED: quote, code and line used to end on BODY with typing going
15573
15646
  // nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
@@ -15579,35 +15652,9 @@
15579
15652
  // what dropped the caret). undoViaToolbar()/redoViaToolbar()/
15580
15653
  // imageViaToolbar() now wrap the same captureCaretForSave()/
15581
15654
  // 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
15655
  //
15598
15656
  // 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.
15657
+ // of this is something the keyboard route introduced.
15611
15658
  function activateToolbarCursor() {
15612
15659
  const btn = toolbarRovingBtns()[toolbarRovingIndex];
15613
15660
  if (!btn || btn.disabled) return;
@@ -15640,7 +15687,7 @@
15640
15687
  btn.type = 'button';
15641
15688
  btn.className = 'ed-toolbar-btn' +
15642
15689
  (TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
15643
- btn.textContent = def.icon;
15690
+ btn.innerHTML = icons.svg(def.icon, 18);
15644
15691
  btn.title = def.title;
15645
15692
  btn.setAttribute('aria-label', def.title);
15646
15693
  btn.setAttribute('data-ed-tb', def.id);
@@ -15693,9 +15740,8 @@
15693
15740
  }, true);
15694
15741
  toolbarBtns = btns;
15695
15742
  // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
15696
- // 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
15697
- // paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
15698
- // 切換時寫入當前模式名稱。
15743
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度。v3.10.0 起它顯示存檔狀態
15744
+ // (paintSaveStatus());原本寫的模式名稱隨原始碼模式一起移除。
15699
15745
  const statusEl = document.createElement('span');
15700
15746
  statusEl.className = 'ed-toolbar-status';
15701
15747
  // Final wave, M7: the only announcement a mode switch ever gets. Every
@@ -15756,7 +15802,6 @@
15756
15802
  // for the caret rescue.
15757
15803
  case 'image': return imageViaToolbar();
15758
15804
  case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
15759
- case 'preview': return cycleDocMode();
15760
15805
  default: return undefined;
15761
15806
  }
15762
15807
  }
@@ -15921,7 +15966,7 @@
15921
15966
  if (!document.body.contains(blockEl)) {
15922
15967
  liveBlockEl = reresolveBlockEl(identity) ||
15923
15968
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
15924
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
15969
+ if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
15925
15970
  }
15926
15971
  const rec = blockRecOf(liveBlockEl);
15927
15972
  // A block that owns no source line has an inverted range, and there is
@@ -16076,14 +16121,14 @@
16076
16121
  // two literals and then parses the bytes. Keep the two rules the same
16077
16122
  // shape, and let the server's refusal be the one the user sees.
16078
16123
  if (!insertableFile(blob)) {
16079
- showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
16124
+ showNotice(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE);
16080
16125
  return null;
16081
16126
  }
16082
16127
  let data;
16083
16128
  try {
16084
16129
  data = await blobToBase64(blob);
16085
16130
  } catch (e) {
16086
- showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
16131
+ showError('圖片讀取失敗 — ' + describeFailure(e), null, null);
16087
16132
  return null;
16088
16133
  }
16089
16134
  let res;
@@ -16095,12 +16140,12 @@
16095
16140
  }),
16096
16141
  });
16097
16142
  } catch (e) {
16098
- showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
16143
+ showError('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
16099
16144
  return null;
16100
16145
  }
16101
16146
  if (!res.ok) {
16102
16147
  const reason = await describeHttpFailure(res);
16103
- showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
16148
+ showError('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
16104
16149
  return null;
16105
16150
  }
16106
16151
  let j;
@@ -16111,11 +16156,11 @@
16111
16156
  // that is not a draw.io diagram -- so the reason has to arrive.
16112
16157
  if (!res.ok) {
16113
16158
  const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
16114
- showBanner('檔案上傳失敗 — ' + why, null, null);
16159
+ showError('檔案上傳失敗 — ' + why, null, null);
16115
16160
  return null;
16116
16161
  }
16117
16162
  if (!j || typeof j.path !== 'string') {
16118
- showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
16163
+ showError('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
16119
16164
  return null;
16120
16165
  }
16121
16166
  return j.path;
@@ -16154,7 +16199,7 @@
16154
16199
  }
16155
16200
  if (!paths.length) return;
16156
16201
  const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
16157
- if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16202
+ if (!target) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16158
16203
  const newLines = [];
16159
16204
  paths.forEach((p, i) => {
16160
16205
  if (i > 0) newLines.push('');
@@ -16219,7 +16264,7 @@
16219
16264
  ? blockEl
16220
16265
  : (reresolveBlockEl(identity) ||
16221
16266
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
16222
- if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16267
+ if (!anchorEl) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16223
16268
  // Re-captured AFTER the commit: `identity` above holds the PRE-commit
16224
16269
  // fingerprint, which the commit we just awaited may itself have
16225
16270
  // invalidated. What has to survive the dialog is the anchor as it stands
@@ -16251,7 +16296,7 @@
16251
16296
  // very symptom being fixed.
16252
16297
  const live = document.body.contains(anchorEl)
16253
16298
  ? anchorEl : reresolveBlockEl(dialogIdentity);
16254
- if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
16299
+ if (!live) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
16255
16300
  insertImages(files, live);
16256
16301
  });
16257
16302
  input.click();
@@ -16279,31 +16324,11 @@
16279
16324
  }
16280
16325
 
16281
16326
  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
16327
  document.addEventListener('dragover', (e) => {
16302
16328
  // Nothing is dropped into a document behind a modal. Returning WITHOUT
16303
16329
  // preventDefault() is what makes the browser refuse the drop, so the
16304
16330
  // gesture is declined visibly rather than swallowed.
16305
16331
  if (waveEditorIsModal()) return;
16306
- if (docMode !== 'edit') return;
16307
16332
  if (!dragCarriesFiles(e)) return;
16308
16333
  e.preventDefault();
16309
16334
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
@@ -16313,7 +16338,6 @@
16313
16338
  // took the document behind it from 4 blocks to 5 and put an `<img>` into
16314
16339
  // `.content`, with the overlay still up and saying nothing.
16315
16340
  if (waveEditorIsModal()) return;
16316
- if (docMode !== 'edit') return;
16317
16341
  if (!dragCarriesFiles(e)) return;
16318
16342
  const files = e.dataTransfer
16319
16343
  ? Array.prototype.slice.call(e.dataTransfer.files || [])
@@ -16433,94 +16457,26 @@
16433
16457
  }, true);
16434
16458
  }
16435
16459
 
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() {
16460
+ // The toolbar's right-hand status slot says whether the file on disk matches
16461
+ // the page. Save stays manual (Ctrl/Cmd+S or the save button); this only
16462
+ // makes the state visible. role="status" (mountToolbar()) so a screen
16463
+ // reader hears it. v3.10.0: it used to name the document mode, which went
16464
+ // with source mode. The save button's aria-pressed follows 'dirty' — save
16465
+ // is not in TOOLBAR_TOGGLE_IDS, so updateToolbar() never writes it back.
16466
+ const SAVE_KEY_HINT = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘S' : 'Ctrl+S';
16467
+ function paintSaveStatus(state) {
16522
16468
  const statusEl = document.querySelector('.ed-toolbar-status');
16523
- if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
16469
+ if (!statusEl) return;
16470
+ // setDirty() runs on every burst mutation; rewriting an unchanged role=
16471
+ // "status" region would have a screen reader re-announce it per keystroke.
16472
+ if (statusEl.getAttribute('data-state') === state) return;
16473
+ statusEl.setAttribute('data-state', state);
16474
+ if (state === 'clean') statusEl.innerHTML = icons.svg('check', 15) + '<span>已儲存</span>';
16475
+ else if (state === 'dirty') statusEl.innerHTML = '<i class="ed-status-dot"></i><span>有未儲存的變更 · ' + SAVE_KEY_HINT + ' 儲存</span>';
16476
+ else if (state === 'saving') statusEl.innerHTML = '<span>儲存中…</span>';
16477
+ else statusEl.innerHTML = icons.svg('circle-alert', 15) + '<span>無法儲存</span>';
16478
+ const btn = toolbarBtns && toolbarBtns.save;
16479
+ if (btn) btn.setAttribute('aria-pressed', state === 'dirty' ? 'true' : 'false');
16524
16480
  }
16525
16481
 
16526
16482
  // ── mount ──────────────────────────────────────────────────────────────
@@ -16600,8 +16556,7 @@
16600
16556
  // while both had to end up with the hint out.
16601
16557
  toolbarEl.addEventListener('scroll', paintToolbarOverflow, { passive: true });
16602
16558
  window.addEventListener('resize', paintToolbarOverflow);
16603
- document.body.setAttribute('data-ed-mode', docMode);
16604
- paintModeStatus();
16559
+ paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
16605
16560
  wireToolbarTracking();
16606
16561
  wireImageDrop();
16607
16562
  wirePlainPasteArming();
@@ -16620,10 +16575,7 @@
16620
16575
  // That bail returns for EVERY key, so an entry gesture placed under it
16621
16576
  // would be dead while a degraded block's raw editor holds focus — driven
16622
16577
  // 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.
16578
+ // the textarea.
16627
16579
  //
16628
16580
  // Escape is NOT claimed here. It belongs to the ordered prologue just
16629
16581
  // below, which already ranks a drag and an open menu above everything
@@ -16789,7 +16741,7 @@
16789
16741
  e.key !== 'Control' && e.key !== 'Meta') {
16790
16742
  exitToolbarKeynav();
16791
16743
  if (e.key !== 'Tab') {
16792
- showBanner(TOOLBAR_KEYNAV_EXIT_MESSAGE, null, null);
16744
+ showNotice(TOOLBAR_KEYNAV_EXIT_MESSAGE);
16793
16745
  activeBannerIsKeynavExit = true;
16794
16746
  }
16795
16747
  }
@@ -17746,7 +17698,7 @@
17746
17698
  return;
17747
17699
  }
17748
17700
  drawioDeferredNotice = null;
17749
- showBanner(message, null, null);
17701
+ showNotice(message);
17750
17702
  }
17751
17703
  function flushDrawioNotice() {
17752
17704
  if (!drawioDeferredNotice) return;
@@ -17757,7 +17709,7 @@
17757
17709
  if (activeBanner || noticeTeardownWouldDisrupt()) return;
17758
17710
  const m = drawioDeferredNotice.message;
17759
17711
  drawioDeferredNotice = null;
17760
- showBanner(m, null, null);
17712
+ showNotice(m);
17761
17713
  }
17762
17714
  const DRAWIO_PAGE_GONE_MSG =
17763
17715
  '目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
@@ -18071,6 +18023,45 @@
18071
18023
  }
18072
18024
  }
18073
18025
 
18026
+ // v3.10.0: an edit made outside the browser. Clean page: reload it and land
18027
+ // on the same heading. Unsaved work here: say so now, once per outside
18028
+ // version, instead of waiting for the save to bounce with a 409.
18029
+ // documentIsDirty() already counts an uncommitted burst, so typing that has
18030
+ // not been committed yet takes the conflict branch (Review Focus 4).
18031
+ let conflictShownFor = 0;
18032
+ function onDiskMtime(disk) {
18033
+ if (disk === mtimeMs || waveEditorIsModal()) return;
18034
+ // An open MD 原始碼 box with typed-but-uncommitted text is unsaved work
18035
+ // too, and documentIsDirty() cannot see it (a textarea's value touches no
18036
+ // block's innerHTML). A reload would silently throw it away.
18037
+ const rawPending = !!(activeEditor && activeEditor.hasChanges());
18038
+ if (!documentIsDirty() && !rawPending) {
18039
+ try { sessionStorage.setItem('md2doc-reload-anchor', JSON.stringify(readingAnchor())); } catch (e) { /* private mode */ }
18040
+ location.reload();
18041
+ return;
18042
+ }
18043
+ if (conflictShownFor === disk) return;
18044
+ conflictShownFor = disk;
18045
+ showConflictBanner();
18046
+ }
18047
+ function readingAnchor() {
18048
+ const hs = [].slice.call(document.querySelectorAll('.content h1[id], .content h2[id], .content h3[id], .content h4[id]'));
18049
+ let best = null;
18050
+ for (const h of hs) { if (h.getBoundingClientRect().top <= 80) best = h; else break; }
18051
+ return best ? { id: best.id, off: Math.round(best.getBoundingClientRect().top) } : { y: Math.round(scrollY) };
18052
+ }
18053
+ (function restoreReloadAnchor() {
18054
+ let a = null;
18055
+ try { a = JSON.parse(sessionStorage.getItem('md2doc-reload-anchor') || 'null'); sessionStorage.removeItem('md2doc-reload-anchor'); } catch (e) { a = null; }
18056
+ if (!a) return;
18057
+ requestAnimationFrame(() => {
18058
+ const h = a.id && document.getElementById(a.id);
18059
+ if (h) scrollTo(0, scrollY + h.getBoundingClientRect().top - a.off);
18060
+ else if (typeof a.y === 'number') scrollTo(0, a.y);
18061
+ showNotice('檔案在別處被修改,已重新載入。');
18062
+ });
18063
+ })();
18064
+
18074
18065
  setInterval(() => {
18075
18066
  // A notice this refresh could not show earlier because the slot was
18076
18067
  // taken (see noteDrawioNotice) gets its next chance here, on the same
@@ -18095,10 +18086,17 @@
18095
18086
  // is the accepted worst-case latency for this whole feature.
18096
18087
  const ack = drawioAckPending;
18097
18088
  drawioAckPending = null;
18089
+ const pingSaveGen = saveGen;
18090
+ const pingSavesInFlight = savesInFlight;
18098
18091
  fetch('/api/ping', {
18099
18092
  method: 'POST', headers: { 'content-type': 'application/json' },
18100
18093
  body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
18101
18094
  }).then((res) => {
18095
+ // v3.10.0: X-Md2doc-Mtime is the file's mtime as the server saw it. Only
18096
+ // comparable when no save started or ran during this round trip.
18097
+ const disk = Number(res.headers.get('x-md2doc-mtime'));
18098
+ if (isFinite(disk) && disk > 0 && saveGen === pingSaveGen &&
18099
+ pingSavesInFlight === 0 && savesInFlight === 0) onDiskMtime(disk);
18102
18100
  if (res.status !== 200) return null;
18103
18101
  return res.json().catch(() => null);
18104
18102
  }).then((j) => {
@@ -18748,7 +18746,7 @@
18748
18746
  // missing from the file after the user has read about it, and the dirty
18749
18747
  // net is about the work, not about the notice.
18750
18748
  let el = null;
18751
- el = showBanner(
18749
+ el = showError(
18752
18750
  '這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
18753
18751
  '比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
18754
18752
  '(不含前後兩道圍欄)。原因:' + reason,
@@ -18866,7 +18864,7 @@
18866
18864
  // Kept, with a hole in it. The user is told once more on the way out,
18867
18865
  // because the status line they were reading has just been removed from
18868
18866
  // the screen along with the rest of the dialog.
18869
- showBanner(
18867
+ showError(
18870
18868
  '這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
18871
18869
  '寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
18872
18870
  seam.endLine + ' 行)。',
@@ -18904,7 +18902,7 @@
18904
18902
  // it. Losing a session is bad; silently corrupting the document the user
18905
18903
  // keeps editing afterwards is worse, and before fix 1 this path risked
18906
18904
  // both at once.
18907
- showBanner(
18905
+ showError(
18908
18906
  '畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
18909
18907
  'Ctrl+Z 可以撤銷)。', null, null);
18910
18908
  }