@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.
- package/lib/editor/client.js +260 -262
- package/lib/editor/icons.js +48 -0
- package/lib/editor/server.js +14 -4
- package/lib/editor/toolbar-model.js +27 -80
- package/lib/highlight.js +35 -0
- package/lib/md2doc.js +267 -154
- package/lib/theme/runtime.js +80 -2
- package/lib/theme/tokens.js +74 -5
- package/package.json +4 -3
- package/lib/editor/docsource.js +0 -115
package/lib/editor/client.js
CHANGED
|
@@ -731,7 +731,7 @@
|
|
|
731
731
|
const toolbarModel = window.md2docToolbarModel;
|
|
732
732
|
const pasteMd = window.md2docPasteMd;
|
|
733
733
|
const assetLib = window.md2docAsset;
|
|
734
|
-
const
|
|
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
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
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.
|
|
1281
|
-
dismissBtn.
|
|
1282
|
-
dismissBtn.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1345
|
-
'
|
|
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
|
-
|
|
1551
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
1589
|
+
showError('這次修改沒有套用:伺服器回傳的內容無法解析。文件維持修改前的樣子。', null, null);
|
|
1564
1590
|
return false;
|
|
1565
1591
|
}
|
|
1566
1592
|
if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) {
|
|
1567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2572
|
-
'
|
|
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.
|
|
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.
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11680
|
+
showNotice('無法刪除標題列');
|
|
11619
11681
|
return;
|
|
11620
11682
|
}
|
|
11621
11683
|
if (bodyRowsOf(liveTableEl).length <= 1) {
|
|
11622
|
-
|
|
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
|
-
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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) {
|
|
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) {
|
|
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.
|
|
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
|
-
|
|
14758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15113
|
-
// affordance (B
|
|
15114
|
-
//
|
|
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
|
-
//
|
|
15333
|
-
// derived label
|
|
15334
|
-
|
|
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 `
|
|
15461
|
-
// the source-mode
|
|
15462
|
-
//
|
|
15463
|
-
//
|
|
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
|
|
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
|
|
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
|
|
15570
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16159
|
+
showError('檔案上傳失敗 — ' + why, null, null);
|
|
16115
16160
|
return null;
|
|
16116
16161
|
}
|
|
16117
16162
|
if (!j || typeof j.path !== 'string') {
|
|
16118
|
-
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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
|
-
//
|
|
16437
|
-
//
|
|
16438
|
-
//
|
|
16439
|
-
//
|
|
16440
|
-
|
|
16441
|
-
|
|
16442
|
-
|
|
16443
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
18905
|
+
showError(
|
|
18908
18906
|
'畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
|
|
18909
18907
|
'Ctrl+Z 可以撤銷)。', null, null);
|
|
18910
18908
|
}
|