@helping-ai-workflow/md2doc 3.9.1 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +1026 -437
- package/lib/editor/convert-md.js +18 -11
- package/lib/editor/icons.js +48 -0
- package/lib/editor/inline-md.js +8 -0
- package/lib/editor/list-md.js +26 -1
- 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 +234 -147
- 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
|
@@ -434,9 +434,9 @@
|
|
|
434
434
|
// reordered span it refused 1425 of the 4067 legal drops; the clamp now
|
|
435
435
|
// answers 1411 of those, and what this still refuses is the 14 no
|
|
436
436
|
// removal-clamp can reach — a batch whose last member is shallower than the
|
|
437
|
-
// block it lands before, i.e. the INSERTION half of the move.
|
|
438
|
-
// performListItemDrop()
|
|
439
|
-
//
|
|
437
|
+
// block it lands before, i.e. the INSERTION half of the move. v3.11: those
|
|
438
|
+
// 14 no longer refuse — performListItemDrop() hands them to
|
|
439
|
+
// moveSpanAcrossLists(), whose own clamp re-anchors the landing.
|
|
440
440
|
function spanIndentsAreAnchored(items) {
|
|
441
441
|
let prevLi = null;
|
|
442
442
|
for (let i = 0; i < (items || []).length; i++) {
|
|
@@ -731,7 +731,7 @@
|
|
|
731
731
|
const toolbarModel = window.md2docToolbarModel;
|
|
732
732
|
const pasteMd = window.md2docPasteMd;
|
|
733
733
|
const assetLib = window.md2docAsset;
|
|
734
|
-
const
|
|
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) {
|
|
@@ -3419,10 +3445,37 @@
|
|
|
3419
3445
|
return out;
|
|
3420
3446
|
}
|
|
3421
3447
|
|
|
3448
|
+
// v3.11 (batch-2 §6): every list serialization goes through here, so every
|
|
3449
|
+
// caller agrees on where a LOOSE list's blank lines go — the commit, and the
|
|
3450
|
+
// line arithmetic (runLineOfBlock(), the per-li commit's lineMeta index)
|
|
3451
|
+
// that has to land on the same lines. Read off the FILE, never guessed from
|
|
3452
|
+
// the DOM: two items that were already neighbours keep whatever separated
|
|
3453
|
+
// them; a new neighbour (a deletion closed the gap, a move or a copy landed,
|
|
3454
|
+
// Enter made the item) takes the spacing of the list it joins, which the
|
|
3455
|
+
// renderer stamped from marked's own looseness as data-list-loose.
|
|
3456
|
+
function serializeListEls(els, opts) {
|
|
3457
|
+
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore }, opts || {}));
|
|
3458
|
+
}
|
|
3459
|
+
function looseBlankBefore(el, prevEl) {
|
|
3460
|
+
if (!prevEl) return false;
|
|
3461
|
+
const rec = blockRecOf(el);
|
|
3462
|
+
const prevRec = blockRecOf(prevEl);
|
|
3463
|
+
if (rec && prevRec && rec !== prevRec && blocks[blocks.indexOf(rec) - 1] === prevRec) {
|
|
3464
|
+
for (let ln = prevRec.endLine + 1; ln < rec.startLine; ln++) {
|
|
3465
|
+
if (String(lines[ln - 1]).trim() === '') return true;
|
|
3466
|
+
}
|
|
3467
|
+
return false;
|
|
3468
|
+
}
|
|
3469
|
+
const loose = (x) => x.getAttribute('data-list-loose') === '1';
|
|
3470
|
+
// A brand-new item (no record yet) carries no list of its own to ask, so
|
|
3471
|
+
// the item after it answers for the list both of them are in.
|
|
3472
|
+
return prevRec ? loose(prevEl) : (loose(prevEl) || loose(el));
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3422
3475
|
function canWysiwygForLi(blockEl) {
|
|
3423
3476
|
if (!blockEl) return false;
|
|
3424
3477
|
if (blockOwnsNoLine(blockEl)) return false;
|
|
3425
|
-
const res =
|
|
3478
|
+
const res = serializeListEls([blockEl]);
|
|
3426
3479
|
return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
|
|
3427
3480
|
}
|
|
3428
3481
|
|
|
@@ -4237,6 +4290,38 @@
|
|
|
4237
4290
|
blockEl.appendChild(buildGutterInsertButton());
|
|
4238
4291
|
blockEl.appendChild(buildGutterHandle());
|
|
4239
4292
|
});
|
|
4293
|
+
alignGutterTops(blockEls);
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// The ⠿ / + pair sits on the block's first line of text, whatever the
|
|
4297
|
+
// block's own margin and line-height (a heading line is 49.5px tall, a
|
|
4298
|
+
// paragraph's 24.75px). Measured from the first real text node's first
|
|
4299
|
+
// line box, so tables, list items and code all land on their first row; a
|
|
4300
|
+
// block with no text (an image, a bare diagram) keeps top 0. armEditables()
|
|
4301
|
+
// also runs on a detached <template> fragment (the partial re-render path),
|
|
4302
|
+
// where nothing has a layout yet: then it waits one frame and measures the
|
|
4303
|
+
// blocks that have been connected by then.
|
|
4304
|
+
function alignGutterTops(blockEls) {
|
|
4305
|
+
if (!blockEls.length || !blockEls[0].isConnected) {
|
|
4306
|
+
if (blockEls.length) requestAnimationFrame(() => alignGutterTops(blockEls.filter((b) => b.isConnected)));
|
|
4307
|
+
return;
|
|
4308
|
+
}
|
|
4309
|
+
for (const b of blockEls) {
|
|
4310
|
+
const walker = document.createTreeWalker(b, NodeFilter.SHOW_TEXT, {
|
|
4311
|
+
acceptNode: (n) => (n.data.trim() && !n.parentElement.closest('.ed-handle, .ed-insert, .ed-li-marker, svg'))
|
|
4312
|
+
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
|
|
4313
|
+
});
|
|
4314
|
+
const t = walker.nextNode();
|
|
4315
|
+
let top = 0;
|
|
4316
|
+
if (t) {
|
|
4317
|
+
const rg = document.createRange();
|
|
4318
|
+
rg.selectNodeContents(t);
|
|
4319
|
+
const line = rg.getClientRects()[0];
|
|
4320
|
+
const handle = b.querySelector('.ed-handle');
|
|
4321
|
+
if (line && handle) top = Math.round(line.top + line.height / 2 - b.getBoundingClientRect().top - handle.offsetHeight / 2);
|
|
4322
|
+
}
|
|
4323
|
+
b.style.setProperty('--ed-pair-top', top + 'px');
|
|
4324
|
+
}
|
|
4240
4325
|
}
|
|
4241
4326
|
|
|
4242
4327
|
// A fresh ⠿ button per block — deliberately NOT wired with its own
|
|
@@ -4248,7 +4333,7 @@
|
|
|
4248
4333
|
const el = document.createElement('button');
|
|
4249
4334
|
el.type = 'button';
|
|
4250
4335
|
el.className = 'ed-handle';
|
|
4251
|
-
el.
|
|
4336
|
+
el.innerHTML = icons.svg('grip-vertical', 16);
|
|
4252
4337
|
el.setAttribute('aria-label', '區塊選項');
|
|
4253
4338
|
// v2.11.1: a <button> is a sequential focus stop, and there is one of
|
|
4254
4339
|
// these plus one + standing immediately after EVERY block — so any Tab
|
|
@@ -4703,7 +4788,7 @@
|
|
|
4703
4788
|
const el = document.createElement('button');
|
|
4704
4789
|
el.type = 'button';
|
|
4705
4790
|
el.className = 'ed-insert';
|
|
4706
|
-
el.
|
|
4791
|
+
el.innerHTML = icons.svg('plus', 16);
|
|
4707
4792
|
el.setAttribute('aria-label', '插入區塊');
|
|
4708
4793
|
// Not a tab stop — see buildGutterHandle() above for the whole reason.
|
|
4709
4794
|
el.setAttribute('tabindex', '-1');
|
|
@@ -4963,7 +5048,7 @@
|
|
|
4963
5048
|
if (!document.body.contains(blockEl)) {
|
|
4964
5049
|
liveBlockEl = reresolveBlockEl(identity) ||
|
|
4965
5050
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
4966
|
-
if (!liveBlockEl) {
|
|
5051
|
+
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
4967
5052
|
}
|
|
4968
5053
|
// T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
|
|
4969
5054
|
// for the same reason and against the same LIVE block. A block that owns
|
|
@@ -5292,14 +5377,14 @@
|
|
|
5292
5377
|
if (!document.body.contains(blockEl)) {
|
|
5293
5378
|
liveBlockEl = reresolveBlockEl(identity) ||
|
|
5294
5379
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
5295
|
-
if (!liveBlockEl) {
|
|
5380
|
+
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
5296
5381
|
}
|
|
5297
5382
|
// A block that owns no source line has an INVERTED range
|
|
5298
5383
|
// (endLine === startLine - 1) and every commit helper handed one does
|
|
5299
5384
|
// something plausible and wrong.
|
|
5300
5385
|
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
|
|
5301
5386
|
const rec = blockRecOf(liveBlockEl);
|
|
5302
|
-
if (!rec) {
|
|
5387
|
+
if (!rec) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
5303
5388
|
if (anchor) anchor.line = rec.startLine;
|
|
5304
5389
|
|
|
5305
5390
|
const res = selectionLib
|
|
@@ -5336,7 +5421,7 @@
|
|
|
5336
5421
|
const els = [];
|
|
5337
5422
|
for (let i = 0; i < members.length; i++) {
|
|
5338
5423
|
const el = blockElById(members[i].id);
|
|
5339
|
-
if (!el) {
|
|
5424
|
+
if (!el) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
5340
5425
|
// Belt to membersOf()'s own braces: selection.js excludes a block that
|
|
5341
5426
|
// owns no line from every member set it builds, so this is unreachable —
|
|
5342
5427
|
// but "another module filters it" is an argument about another file's
|
|
@@ -5369,58 +5454,20 @@
|
|
|
5369
5454
|
|
|
5370
5455
|
const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
|
|
5371
5456
|
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
5372
|
-
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
5373
5457
|
const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
|
|
5374
|
-
//
|
|
5375
|
-
//
|
|
5376
|
-
|
|
5377
|
-
//
|
|
5378
|
-
//
|
|
5379
|
-
//
|
|
5380
|
-
//
|
|
5381
|
-
// owned the final wording. They are three different problems with three
|
|
5382
|
-
// OPPOSITE remedies, so one sentence for all of them sends two thirds of
|
|
5383
|
-
// the users to fix the wrong end of their own gesture:
|
|
5384
|
-
// * the SOURCE seam — the block cannot leave where it is, because the two
|
|
5385
|
-
// lists it stands between would join into one loose run. No destination
|
|
5386
|
-
// helps; the remedy is not to move it (or to move something else).
|
|
5387
|
-
// * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
|
|
5388
|
-
// is to aim somewhere else, which is the exact opposite advice.
|
|
5389
|
-
// * OUT OF RUN — a list item may only move among its own §3.8 siblings.
|
|
5390
|
-
// The remedy is to stay inside its own list.
|
|
5391
|
-
// Naming follows the BATCH_*_MESSAGE convention above. None of the three is
|
|
5392
|
-
// a substring of another, of §4.3's own gate message, or of
|
|
5393
|
-
// BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
|
|
5394
|
-
// refusal fired rather than that some banner mentioning 清單 appeared.
|
|
5395
|
-
const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
|
|
5458
|
+
// The ⠿ drag's one remaining cross-boundary refusal: a drop onto a list
|
|
5459
|
+
// item that owns no source line (the outer item of a same-line nest such
|
|
5460
|
+
// as `- - b`) — there is no line to land in front of. v3.11 retired the
|
|
5461
|
+
// other four (source seam, destination seam inside a list, out of the run,
|
|
5462
|
+
// an orphaned landing): moveSpanAcrossLists() performs those moves instead.
|
|
5463
|
+
// Not a substring of any other refusal in this file, which is what lets a
|
|
5464
|
+
// test assert WHICH one fired.
|
|
5396
5465
|
const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
|
|
5397
|
-
const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
|
|
5398
|
-
// S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
|
|
5399
|
-
// of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
|
|
5400
|
-
// removal half of the move through clampIndents()'s `{ removed: true }`),
|
|
5401
|
-
// which answers every SOURCE-side orphan. What no removal-clamp can reach is
|
|
5402
|
-
// the INSERTION half: a BATCH whose last member is SHALLOWER than the block
|
|
5403
|
-
// it lands before leaves that block with no parent chain above it, at the
|
|
5404
|
-
// NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
|
|
5405
|
-
// indent array of length 2..6 and depth 0..3, every operand-set size and
|
|
5406
|
-
// every destination the run gate admits: 4067 drops, 1425 refused by Task
|
|
5407
|
-
// 4's predicate, 14 still unanchored after the clamp — every one of them a
|
|
5408
|
-
// multi-block set breaking at the block immediately after the landed set.
|
|
5409
|
-
// (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
|
|
5410
|
-
//
|
|
5411
|
-
// RE-WORDED with the narrowing, and the wording is the point: Task 4's
|
|
5412
|
-
// 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
|
|
5413
|
-
// was true while the capability was missing and is a lie now that it is not.
|
|
5414
|
-
// What is left is a DESTINATION objection with the same remedy
|
|
5415
|
-
// BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
|
|
5416
|
-
// end of the gesture is at fault. Still not a substring of any other refusal
|
|
5417
|
-
// in this file, which is what lets a test assert WHICH one fired.
|
|
5418
|
-
const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
|
|
5419
5466
|
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
5420
5467
|
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
5421
5468
|
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
5422
5469
|
// toggleGutterMenu(), where the menu-hiding call site can share them. The
|
|
5423
|
-
//
|
|
5470
|
+
// constants above are shape-level and belong to every batch operation.
|
|
5424
5471
|
|
|
5425
5472
|
function blockElById(id) {
|
|
5426
5473
|
return document.querySelector('.ed-block[data-block-id="' + id + '"]');
|
|
@@ -5670,9 +5717,11 @@
|
|
|
5670
5717
|
// null (dropped gesture, no source
|
|
5671
5718
|
// line, empty/gapped/no-element
|
|
5672
5719
|
// batch)
|
|
5673
|
-
// 2.
|
|
5674
|
-
//
|
|
5675
|
-
//
|
|
5720
|
+
// 2. (v3.11: retired — a mixed / withheld / two-list span routes to
|
|
5721
|
+
// convertSpanAcrossLists(), which has its own exits below)
|
|
5722
|
+
// 3. the all-withheld refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
|
|
5723
|
+
// 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE; now
|
|
5724
|
+
// unreachable, kept as a guard)
|
|
5676
5725
|
// 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
|
|
5677
5726
|
// 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
|
|
5678
5727
|
// — six, not three, plus `result.op === null` (a commit that wrote nothing)
|
|
@@ -5718,41 +5767,20 @@
|
|
|
5718
5767
|
const els = operands.els;
|
|
5719
5768
|
const recs = operands.recs;
|
|
5720
5769
|
const shape = spanListKinds(els);
|
|
5721
|
-
|
|
5722
|
-
//
|
|
5723
|
-
//
|
|
5724
|
-
//
|
|
5725
|
-
//
|
|
5726
|
-
//
|
|
5727
|
-
//
|
|
5728
|
-
//
|
|
5729
|
-
//
|
|
5730
|
-
//
|
|
5731
|
-
//
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
// byte-identical.
|
|
5736
|
-
//
|
|
5737
|
-
// The banner names the type that is ACTUALLY in the span, not one of the
|
|
5738
|
-
// four shape-level messages above: BATCH_MIXED names list items vs other
|
|
5739
|
-
// blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
|
|
5740
|
-
// names non-contiguity — either would name something the user's selection
|
|
5741
|
-
// has not done. So would telling a user with an hr in the selection that it
|
|
5742
|
-
// contains a table.
|
|
5743
|
-
//
|
|
5744
|
-
// SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
|
|
5745
|
-
// holding any of these types are measured correct and have no equivalent
|
|
5746
|
-
// objection — they do not need a target that can carry cells, or a marker
|
|
5747
|
-
// to strip — so the predicate deliberately does NOT reach
|
|
5748
|
-
// resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
|
|
5749
|
-
// those five cells as APPLYING so a widening shows up as a failure too.
|
|
5750
|
-
// Placed BELOW the mixed gate so no span that already refuses changes which
|
|
5751
|
-
// banner it gets.
|
|
5752
|
-
const withheld = shape.kinds.filter(convertWithheldFor)[0];
|
|
5753
|
-
if (withheld) {
|
|
5754
|
-
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
|
|
5755
|
-
return;
|
|
5770
|
+
// §3.7 / §7 withhold 轉換成 from a table, an hr and a raw html block —
|
|
5771
|
+
// toggleGutterMenu() hides the item outright on a single one of them.
|
|
5772
|
+
// v3.11: a SPAN holding one converts its other members and keeps the
|
|
5773
|
+
// withheld ones byte for byte, with a notice naming what was skipped
|
|
5774
|
+
// (Notion skips what it cannot turn); a span mixing list items with other
|
|
5775
|
+
// blocks, or taking items of two lists, converts as well. All three used
|
|
5776
|
+
// to refuse (BATCH_MIXED_MESSAGE / BATCH_MULTIRUN_MESSAGE /
|
|
5777
|
+
// BATCH_CONVERT_WITHHELD_MESSAGES). Before the withheld refusal existed a
|
|
5778
|
+
// table span wrote '> | A | B |' … — convertSpanAcrossLists() never
|
|
5779
|
+
// strips a withheld member, which is why it is the route for all of them.
|
|
5780
|
+
// A span of ONLY withheld blocks still refuses: nothing in it converts.
|
|
5781
|
+
if ((shape.anyLi && !shape.allLi) || shape.kinds.some(convertWithheldFor) ||
|
|
5782
|
+
(shape.allLi && !batchRunOf(els))) {
|
|
5783
|
+
return await convertSpanAcrossLists(els, recs, shape.kinds, target);
|
|
5756
5784
|
}
|
|
5757
5785
|
|
|
5758
5786
|
// §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
|
|
@@ -5761,25 +5789,20 @@
|
|
|
5761
5789
|
// is exactly what listRunOf() returns (the outermost run PLUS every
|
|
5762
5790
|
// descendant of its members) — see deleteListItemsViaGutter()'s own note.
|
|
5763
5791
|
//
|
|
5764
|
-
//
|
|
5765
|
-
//
|
|
5766
|
-
//
|
|
5767
|
-
// convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
|
|
5768
|
-
// would get if stripMarker() saw it first (that function refuses a
|
|
5769
|
-
// multi-line li too, for its own, narrower reason). The runtime scenario
|
|
5770
|
-
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
|
|
5771
|
-
// is what notices if this order is ever flipped.
|
|
5792
|
+
// It sits AHEAD of the per-target routing below, so a run the gate
|
|
5793
|
+
// refuses (an unsupported shape) reports §4.1's
|
|
5794
|
+
// 「此清單含不支援的格式,無法調整結構」 before anything else is tried.
|
|
5772
5795
|
//
|
|
5773
|
-
//
|
|
5774
|
-
//
|
|
5775
|
-
//
|
|
5776
|
-
//
|
|
5777
|
-
//
|
|
5796
|
+
// v3.11: { replaysTargets: true } — a multi-line li converts (a li→li
|
|
5797
|
+
// change replays its bytes with the marker's column delta; li → non-list
|
|
5798
|
+
// reads them from `lines` through stripMarker()), so §4.1 修訂 2's
|
|
5799
|
+
// TARGET refusal no longer applies. S3 Task 6 passes the WHOLE operand set
|
|
5800
|
+
// — every member is being rewritten, so every member clears the gate.
|
|
5778
5801
|
let liRun = null;
|
|
5779
5802
|
if (shape.allLi) {
|
|
5780
5803
|
liRun = batchRunOf(els);
|
|
5781
5804
|
if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
5782
|
-
if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
|
|
5805
|
+
if (!listRunSupportsStructuralEdit(liRun, els, { replaysTargets: true })) { refuseStructuralListEdit(); return; }
|
|
5783
5806
|
}
|
|
5784
5807
|
|
|
5785
5808
|
// S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
|
|
@@ -5955,7 +5978,12 @@
|
|
|
5955
5978
|
const els = operands.els;
|
|
5956
5979
|
const recs = operands.recs;
|
|
5957
5980
|
const shape = spanListKinds(els);
|
|
5958
|
-
|
|
5981
|
+
// v3.11: a span mixing list items with other blocks, or taking items of
|
|
5982
|
+
// two lists, duplicates too (it used to refuse with BATCH_MIXED_MESSAGE /
|
|
5983
|
+
// BATCH_MULTIRUN_MESSAGE).
|
|
5984
|
+
if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
|
|
5985
|
+
return (await duplicateSpanAcrossLists(els, recs)) ? recs[0].startLine : undefined;
|
|
5986
|
+
}
|
|
5959
5987
|
|
|
5960
5988
|
if (shape.allLi) {
|
|
5961
5989
|
// recs[0].startLine is unchanged by this commit — duplicateListItems()'s
|
|
@@ -6010,6 +6038,60 @@
|
|
|
6010
6038
|
// of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
|
|
6011
6039
|
// a refusal (nothing was duplicated, the block is still where it was) apart
|
|
6012
6040
|
// from a completed duplicate.
|
|
6041
|
+
// v3.11: 建立副本 over a span that mixes list items with other blocks, or
|
|
6042
|
+
// takes items of two lists. The copy is the span's own source lines,
|
|
6043
|
+
// byte for byte, inserted after the span (after the last member's subtree
|
|
6044
|
+
// when that member is a list item, like duplicateListItems()), in ONE
|
|
6045
|
+
// commit. Where the copy meets a neighbour it joins tight when both sides
|
|
6046
|
+
// are list items — the copied items rejoin the list, as in Notion — and is
|
|
6047
|
+
// kept apart by a blank line otherwise.
|
|
6048
|
+
async function duplicateSpanAcrossLists(els, recs) {
|
|
6049
|
+
const first = recs[0];
|
|
6050
|
+
const lastEl = els[els.length - 1];
|
|
6051
|
+
let endRec = recs[recs.length - 1];
|
|
6052
|
+
if (lastEl.getAttribute('data-block-type') === 'li') {
|
|
6053
|
+
const sub = subtreeBlocksAfter(lastEl, Number(lastEl.getAttribute('data-indent')) || 0);
|
|
6054
|
+
const subRec = sub.length ? blockRecOf(sub[sub.length - 1]) : null;
|
|
6055
|
+
if (subRec) endRec = subRec;
|
|
6056
|
+
}
|
|
6057
|
+
const at = endRec.endLine;
|
|
6058
|
+
const copy = lines.slice(first.startLine - 1, recs[recs.length - 1].endLine);
|
|
6059
|
+
const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
|
|
6060
|
+
const endEl = blockElById(endRec.id);
|
|
6061
|
+
const nextRec = blocks[blocks.indexOf(endRec) + 1] || null;
|
|
6062
|
+
const nextEl = nextRec ? blockElById(nextRec.id) : null;
|
|
6063
|
+
const nextLine = at < lines.length ? String(lines[at]) : null;
|
|
6064
|
+
const out = [];
|
|
6065
|
+
const tightBefore = isLi(endEl) && isLi(els[0]);
|
|
6066
|
+
if (!tightBefore) {
|
|
6067
|
+
// A blank line in front: an indented first line would read as code, and
|
|
6068
|
+
// an ordered item restarts its own count.
|
|
6069
|
+
if (/^( {4}|\t)/.test(copy[0])) { refuseStructuralListEdit(); return false; }
|
|
6070
|
+
if (isLi(els[0])) copy[0] = copy[0].replace(/^(\s*)\d+([.)])/, '$11$2');
|
|
6071
|
+
out.push('');
|
|
6072
|
+
}
|
|
6073
|
+
out.push(...copy);
|
|
6074
|
+
const tightAfter = isLi(lastEl) && isLi(nextEl) && nextLine !== null && nextLine.trim() !== '';
|
|
6075
|
+
if (!tightAfter && nextLine !== null && nextLine.trim() !== '') out.push('');
|
|
6076
|
+
const result = commitRangeEdit({ lines, blocks, stack }, at, at,
|
|
6077
|
+
[lines[at - 1]].concat(out).join('\n'));
|
|
6078
|
+
if (result.op === null) return false;
|
|
6079
|
+
endBurstWithoutResolve();
|
|
6080
|
+
const prevLines = lines;
|
|
6081
|
+
lines = result.lines;
|
|
6082
|
+
declareCollapse({ startLine: first.startLine, endLine: recs[recs.length - 1].endLine });
|
|
6083
|
+
const editRange = result.op
|
|
6084
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6085
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6086
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6087
|
+
: undefined;
|
|
6088
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
6089
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6090
|
+
return false;
|
|
6091
|
+
}
|
|
6092
|
+
return true;
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6013
6095
|
async function duplicateListItems(liEls, recs) {
|
|
6014
6096
|
// Every list batch is one run's problem — see batchRunOf().
|
|
6015
6097
|
const run = batchRunOf(liEls);
|
|
@@ -6021,7 +6103,7 @@
|
|
|
6021
6103
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
6022
6104
|
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
6023
6105
|
// member is a target.
|
|
6024
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
6106
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
|
|
6025
6107
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
6026
6108
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
6027
6109
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
@@ -6204,15 +6286,13 @@
|
|
|
6204
6286
|
const els = operands.els;
|
|
6205
6287
|
const recs = operands.recs;
|
|
6206
6288
|
const shape = spanListKinds(els);
|
|
6207
|
-
// A mixed span
|
|
6208
|
-
//
|
|
6209
|
-
//
|
|
6210
|
-
//
|
|
6211
|
-
//
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
refuseStructuralListEdit(BATCH_PARTIAL_LIST_DELETE_MESSAGE); return;
|
|
6215
|
-
}
|
|
6289
|
+
// A mixed span that covers every list it touches WHOLE is one contiguous
|
|
6290
|
+
// line range like any non-list span -- the commitRangeRemoval() branch
|
|
6291
|
+
// below, one undo op. v3.11: a list covered only in PART (or items of two
|
|
6292
|
+
// lists) no longer refuses — deleteSpanAcrossLists(), routed below once
|
|
6293
|
+
// the caret landing has been read.
|
|
6294
|
+
const acrossLists = (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) ||
|
|
6295
|
+
(shape.allLi && !batchRunOf(els));
|
|
6216
6296
|
// Fix round 1, item 2: the block immediately ABOVE the span, read out of
|
|
6217
6297
|
// the PRE-commit `blocks` and carried to the wrapper. It cannot be
|
|
6218
6298
|
// recovered afterwards from the hole's coordinate — see
|
|
@@ -6244,6 +6324,10 @@
|
|
|
6244
6324
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
6245
6325
|
// below corrupts a list three separate ways — see
|
|
6246
6326
|
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
6327
|
+
if (acrossLists) {
|
|
6328
|
+
return (await deleteSpanAcrossLists(els, recs))
|
|
6329
|
+
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
6330
|
+
}
|
|
6247
6331
|
if (shape.allLi) {
|
|
6248
6332
|
return (await deleteListItemsViaGutter(els))
|
|
6249
6333
|
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
@@ -6313,6 +6397,94 @@
|
|
|
6313
6397
|
// of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
|
|
6314
6398
|
// a refusal (nothing was removed, the block is still where it was) apart from
|
|
6315
6399
|
// a completed delete before it goes looking for a neighbour.
|
|
6400
|
+
// v3.11: 刪除 over a span that cuts into one or more lists. ONE commit (one
|
|
6401
|
+
// undo step) over the region from the first touched list's first line to
|
|
6402
|
+
// the last touched list's last line: every touched list keeps its unselected
|
|
6403
|
+
// items (re-serialized the way deleteListItemsViaGutter() does it, bystander
|
|
6404
|
+
// bytes replayed), every other selected block is dropped. Two lists whose
|
|
6405
|
+
// survivors end up adjacent join tight — Notion's behaviour; the serializer
|
|
6406
|
+
// renumbers every ordered piece from 1, so the join is always well-formed.
|
|
6407
|
+
async function deleteSpanAcrossLists(els, recs) {
|
|
6408
|
+
const groups = [];
|
|
6409
|
+
for (let i = 0; i < els.length; i++) {
|
|
6410
|
+
const el = els[i];
|
|
6411
|
+
if (el.getAttribute('data-block-type') !== 'li') continue;
|
|
6412
|
+
let g = groups.find((x) => x.run.indexOf(el) !== -1);
|
|
6413
|
+
if (!g) {
|
|
6414
|
+
const run = listRunOf(el);
|
|
6415
|
+
if (!run.length) { refuseStructuralListEdit(); return false; }
|
|
6416
|
+
g = { run: run, targets: [] };
|
|
6417
|
+
groups.push(g);
|
|
6418
|
+
}
|
|
6419
|
+
g.targets.push(el);
|
|
6420
|
+
}
|
|
6421
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6422
|
+
const g = groups[i];
|
|
6423
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
|
|
6424
|
+
refuseStructuralListEdit(); return false;
|
|
6425
|
+
}
|
|
6426
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
6427
|
+
if (!g.range) return false;
|
|
6428
|
+
}
|
|
6429
|
+
let lo = recs[0].startLine;
|
|
6430
|
+
let hi = recs[recs.length - 1].endLine;
|
|
6431
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
6432
|
+
// Everything inside [lo, hi] must be either selected or one of a touched
|
|
6433
|
+
// list's own lines — anything else would be silently lost, so refuse.
|
|
6434
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
6435
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
6436
|
+
const b = blocks[i];
|
|
6437
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
6438
|
+
const k = recs.indexOf(b);
|
|
6439
|
+
if (k !== -1 && els[k].getAttribute('data-block-type') !== 'li' && inRun(b)) {
|
|
6440
|
+
refuseStructuralListEdit(); return false;
|
|
6441
|
+
}
|
|
6442
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return false; }
|
|
6443
|
+
}
|
|
6444
|
+
groups.forEach((g) => {
|
|
6445
|
+
g.survivors = g.run.filter((el) => g.targets.indexOf(el) === -1);
|
|
6446
|
+
});
|
|
6447
|
+
mutateListRun(() => {
|
|
6448
|
+
groups.forEach((g) => {
|
|
6449
|
+
applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { removed: true });
|
|
6450
|
+
g.targets.forEach((el) => removeListItem(el));
|
|
6451
|
+
});
|
|
6452
|
+
});
|
|
6453
|
+
const carry = bystanderCarryOver([].concat(...groups.map((g) => g.survivors)));
|
|
6454
|
+
const pieces = groups
|
|
6455
|
+
.map((g) => g.survivors.length ? serializeListEls(g.survivors, { carryOver: carry }).md : '')
|
|
6456
|
+
.filter((piece) => piece !== '');
|
|
6457
|
+
let md = pieces.join('\n');
|
|
6458
|
+
if (md !== '') {
|
|
6459
|
+
// A removed block at an edge of the region: keep the new text apart
|
|
6460
|
+
// from a neighbour that is not a blank line.
|
|
6461
|
+
const head = groups[0];
|
|
6462
|
+
const tail = groups[groups.length - 1];
|
|
6463
|
+
const startsInRun = head && head.range.startLine === lo && head.survivors.length;
|
|
6464
|
+
const endsInRun = tail && tail.range.endLine === hi && tail.survivors.length;
|
|
6465
|
+
if (!startsInRun && lo > 1 && String(lines[lo - 2]).trim() !== '') md = '\n' + md;
|
|
6466
|
+
if (!endsInRun && hi < lines.length && String(lines[hi]).trim() !== '') md = md + '\n';
|
|
6467
|
+
}
|
|
6468
|
+
declareCollapse(null);
|
|
6469
|
+
const result = md === ''
|
|
6470
|
+
? commitRangeRemoval({ lines, blocks, stack }, lo, hi)
|
|
6471
|
+
: commitRangeEdit({ lines, blocks, stack }, lo, hi, md);
|
|
6472
|
+
endBurstWithoutResolve();
|
|
6473
|
+
const prevLines = lines;
|
|
6474
|
+
if (result.op !== null) lines = result.lines;
|
|
6475
|
+
const editRange = result.op
|
|
6476
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6477
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6478
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6479
|
+
: undefined;
|
|
6480
|
+
const ok = await safeRerenderAll(editRange);
|
|
6481
|
+
if (!ok) {
|
|
6482
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6483
|
+
return false;
|
|
6484
|
+
}
|
|
6485
|
+
return true;
|
|
6486
|
+
}
|
|
6487
|
+
|
|
6316
6488
|
async function deleteListItemsViaGutter(liEls) {
|
|
6317
6489
|
// Every list batch is one run's problem — see batchRunOf().
|
|
6318
6490
|
const run = batchRunOf(liEls);
|
|
@@ -6323,7 +6495,7 @@
|
|
|
6323
6495
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
6324
6496
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
6325
6497
|
// target refuses per §4.1 — and in a batch every member is a target.
|
|
6326
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
6498
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
|
|
6327
6499
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
6328
6500
|
if (!range) return false;
|
|
6329
6501
|
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
@@ -6499,6 +6671,162 @@
|
|
|
6499
6671
|
};
|
|
6500
6672
|
}
|
|
6501
6673
|
|
|
6674
|
+
// v3.11: 轉換成 over a span the single-kind paths cannot take — list items
|
|
6675
|
+
// mixed with other blocks, items of two lists, or a table / hr / html block
|
|
6676
|
+
// among the members (those three have no content to re-host, so they are
|
|
6677
|
+
// kept as they are and a notice says so — Notion skips what it cannot
|
|
6678
|
+
// turn). ONE commit over the region from the first touched list's first
|
|
6679
|
+
// line to the last touched list's last line: unselected items before the
|
|
6680
|
+
// span (first list) and after it (last list) are re-serialized the way
|
|
6681
|
+
// convertListItemsAway() does it, orphaned children clamped; every selected
|
|
6682
|
+
// block is stripMarker() → emitAs()'d from its own source lines. A list
|
|
6683
|
+
// target turns the members into one list (nested items keep their nesting,
|
|
6684
|
+
// re-based on the first member); pieces that are list items on both sides
|
|
6685
|
+
// of a seam join tight.
|
|
6686
|
+
async function convertSpanAcrossLists(els, recs, kinds, target) {
|
|
6687
|
+
const toList = convertMd.targetIsList(target);
|
|
6688
|
+
const isLi = (el) => el.getAttribute('data-block-type') === 'li';
|
|
6689
|
+
const groups = [];
|
|
6690
|
+
for (let i = 0; i < els.length; i++) {
|
|
6691
|
+
if (!isLi(els[i])) continue;
|
|
6692
|
+
let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
|
|
6693
|
+
if (!g) {
|
|
6694
|
+
const run = listRunOf(els[i]);
|
|
6695
|
+
if (!run.length) { refuseStructuralListEdit(); return; }
|
|
6696
|
+
g = { run: run, targets: [] };
|
|
6697
|
+
groups.push(g);
|
|
6698
|
+
}
|
|
6699
|
+
g.targets.push(els[i]);
|
|
6700
|
+
}
|
|
6701
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6702
|
+
const g = groups[i];
|
|
6703
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
|
|
6704
|
+
refuseStructuralListEdit(); return;
|
|
6705
|
+
}
|
|
6706
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
6707
|
+
if (!g.range) return;
|
|
6708
|
+
}
|
|
6709
|
+
let lo = recs[0].startLine;
|
|
6710
|
+
let hi = recs[recs.length - 1].endLine;
|
|
6711
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
6712
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
6713
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
6714
|
+
const b = blocks[i];
|
|
6715
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
6716
|
+
const k = recs.indexOf(b);
|
|
6717
|
+
if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
|
|
6718
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
|
|
6719
|
+
}
|
|
6720
|
+
// Unselected items may only sit BEFORE the span (in its first list) or
|
|
6721
|
+
// AFTER it (in its last list) — the span is one contiguous line range.
|
|
6722
|
+
const firstEl = els[0];
|
|
6723
|
+
const lastEl = els[els.length - 1];
|
|
6724
|
+
const allEls = allBlockEls();
|
|
6725
|
+
const before = [];
|
|
6726
|
+
const after = [];
|
|
6727
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6728
|
+
const g = groups[i];
|
|
6729
|
+
for (let j = 0; j < g.run.length; j++) {
|
|
6730
|
+
const el = g.run[j];
|
|
6731
|
+
if (g.targets.indexOf(el) !== -1) continue;
|
|
6732
|
+
if (allEls.indexOf(el) < allEls.indexOf(firstEl)) before.push(el);
|
|
6733
|
+
else if (allEls.indexOf(el) > allEls.indexOf(lastEl)) after.push(el);
|
|
6734
|
+
else { refuseStructuralListEdit(); return; }
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6737
|
+
|
|
6738
|
+
// The members, converted. A run of consecutive convertible members is one
|
|
6739
|
+
// chunk; a withheld member is its own chunk, kept byte for byte.
|
|
6740
|
+
const skipped = [];
|
|
6741
|
+
const chunks = [];
|
|
6742
|
+
let cur = null;
|
|
6743
|
+
let level0 = null;
|
|
6744
|
+
const ordinals = [];
|
|
6745
|
+
for (let i = 0; i < els.length; i++) {
|
|
6746
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
6747
|
+
if (convertWithheldFor(kinds[i])) {
|
|
6748
|
+
if (skipped.indexOf(kinds[i]) === -1) skipped.push(kinds[i]);
|
|
6749
|
+
chunks.push({ list: false, text: src.join('\n') });
|
|
6750
|
+
cur = null;
|
|
6751
|
+
ordinals.length = 0;
|
|
6752
|
+
continue;
|
|
6753
|
+
}
|
|
6754
|
+
const stripped = convertMd.stripMarker(src, kinds[i]);
|
|
6755
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
6756
|
+
if (!toList) {
|
|
6757
|
+
chunks.push({ list: false, text: convertMd.emitAs(stripped.content, target, {}).join('\n') });
|
|
6758
|
+
continue;
|
|
6759
|
+
}
|
|
6760
|
+
// Nesting, re-based so the first member sits at level 0 and no item
|
|
6761
|
+
// is ever more than one level deeper than the one above it.
|
|
6762
|
+
let level = isLi(els[i]) ? (Number(els[i].getAttribute('data-indent')) || 0) : 0;
|
|
6763
|
+
if (level0 === null) level0 = level;
|
|
6764
|
+
level = Math.max(0, level - level0);
|
|
6765
|
+
if (!cur) { cur = { list: true, lines: [], prevLevel: -1 }; chunks.push(cur); ordinals.length = 0; }
|
|
6766
|
+
level = Math.min(level, cur.prevLevel + 1);
|
|
6767
|
+
cur.prevLevel = level;
|
|
6768
|
+
ordinals.length = level + 1;
|
|
6769
|
+
ordinals[level] = (ordinals[level] || 0) + 1;
|
|
6770
|
+
const width = target === 'ol' ? 3 : 2;
|
|
6771
|
+
const checked = /^\s*(?:[-*+]|\d+[.)])\s+\[[xX]\]/.test(src[0]);
|
|
6772
|
+
cur.lines.push(...convertMd.emitAs(stripped.content, target,
|
|
6773
|
+
{ ordinal: ordinals[level], checked: checked, indentPrefix: ' '.repeat(level * width) }));
|
|
6774
|
+
}
|
|
6775
|
+
const middle = chunks.map((c) => (c.list ? { list: true, text: c.lines.join('\n') } : c));
|
|
6776
|
+
if (skipped.length === middle.length) {
|
|
6777
|
+
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[skipped[0]]); return;
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
// Orphans after the span lose an ancestor that is no longer an item.
|
|
6781
|
+
if (!toList && after.length && groups.length) {
|
|
6782
|
+
const g = groups[groups.length - 1];
|
|
6783
|
+
mutateListRun(() => {
|
|
6784
|
+
applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { operatedBecomes: { type: convertedBlockType(target) } });
|
|
6785
|
+
});
|
|
6786
|
+
}
|
|
6787
|
+
const carry = bystanderCarryOver(before.concat(after));
|
|
6788
|
+
const pieces = [];
|
|
6789
|
+
if (before.length) pieces.push({ list: true, text: serializeListEls(before, { carryOver: carry }).md });
|
|
6790
|
+
const midFrom = pieces.length;
|
|
6791
|
+
pieces.push(...middle);
|
|
6792
|
+
const midTo = pieces.length;
|
|
6793
|
+
if (after.length) pieces.push({ list: true, text: serializeListEls(after, { carryOver: carry }).md });
|
|
6794
|
+
const out = [];
|
|
6795
|
+
let midStart = null;
|
|
6796
|
+
let midEnd = null;
|
|
6797
|
+
pieces.forEach((p, i) => {
|
|
6798
|
+
if (i > 0) {
|
|
6799
|
+
if (!(p.list && pieces[i - 1].list)) out.push('');
|
|
6800
|
+
}
|
|
6801
|
+
if (i === midFrom) midStart = out.length;
|
|
6802
|
+
out.push(...p.text.split('\n'));
|
|
6803
|
+
if (i === midTo - 1) midEnd = out.length - 1;
|
|
6804
|
+
});
|
|
6805
|
+
let lead = 0;
|
|
6806
|
+
if (!before.length && lo > 1 && String(lines[lo - 2]).trim() !== '') { out.unshift(''); lead = 1; }
|
|
6807
|
+
if (!after.length && hi < lines.length && String(lines[hi]).trim() !== '') out.push('');
|
|
6808
|
+
|
|
6809
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
|
|
6810
|
+
if (result.op === null) return;
|
|
6811
|
+
endBurstWithoutResolve();
|
|
6812
|
+
const prevLines = lines;
|
|
6813
|
+
lines = result.lines;
|
|
6814
|
+
declareCollapse({ startLine: lo + lead + midStart, endLine: lo + lead + midEnd });
|
|
6815
|
+
const editRange = result.op
|
|
6816
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6817
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6818
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6819
|
+
: undefined;
|
|
6820
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
6821
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6822
|
+
return;
|
|
6823
|
+
}
|
|
6824
|
+
if (skipped.length) {
|
|
6825
|
+
showNotice('已略過無法轉換的區塊:' + skipped.map((k) => CONVERT_SKIP_LABELS[k]).join('、'));
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6828
|
+
const CONVERT_SKIP_LABELS = { table: '表格', hr: '分隔線', html: 'HTML 區塊' };
|
|
6829
|
+
|
|
6502
6830
|
async function convertListItemsType(liEls, run, recs, target) {
|
|
6503
6831
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
6504
6832
|
if (!range) return;
|
|
@@ -6680,13 +7008,13 @@
|
|
|
6680
7008
|
// back through escapeText() — see convertListItemsType()'s note.
|
|
6681
7009
|
const carry = bystanderCarryOver(before.concat(after));
|
|
6682
7010
|
const pieces = [];
|
|
6683
|
-
if (before.length) pieces.push(
|
|
7011
|
+
if (before.length) pieces.push(serializeListEls(before, { carryOver: carry }).md);
|
|
6684
7012
|
// Which piece the converted blocks are — the collapse range below counts
|
|
6685
7013
|
// emitted LINES up to it, and "0 or 1" is only true while `before` is the
|
|
6686
7014
|
// one optional piece ahead of it.
|
|
6687
7015
|
const convertedPieceIdx = pieces.length;
|
|
6688
7016
|
pieces.push(convertedMd);
|
|
6689
|
-
if (after.length) pieces.push(
|
|
7017
|
+
if (after.length) pieces.push(serializeListEls(after, { carryOver: carry }).md);
|
|
6690
7018
|
let md = pieces.join('\n\n');
|
|
6691
7019
|
|
|
6692
7020
|
// §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
|
|
@@ -7088,7 +7416,7 @@
|
|
|
7088
7416
|
// source range.
|
|
7089
7417
|
const carry = bystanderCarryOver(runEls, editedLiEl);
|
|
7090
7418
|
const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
|
|
7091
|
-
|
|
7419
|
+
serializeListEls(runEls, { carryOver: carry });
|
|
7092
7420
|
// Refuse if the EDITED li itself has unsupported inline content.
|
|
7093
7421
|
// Show banner + teardown + rerenderAll (file is untouched, burst never
|
|
7094
7422
|
// wrote to `lines`) + return false — this path does NOT fall back to
|
|
@@ -7106,7 +7434,7 @@
|
|
|
7106
7434
|
// not before: the user can reach exactly that through the ⠿ menu.
|
|
7107
7435
|
const editedIdStr = String(burst.blockId);
|
|
7108
7436
|
if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
|
|
7109
|
-
|
|
7437
|
+
showNotice('含不支援的格式,改用原始碼編輯');
|
|
7110
7438
|
endBurstWithoutResolve();
|
|
7111
7439
|
await safeRerenderAll();
|
|
7112
7440
|
return false;
|
|
@@ -7114,7 +7442,15 @@
|
|
|
7114
7442
|
const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
|
|
7115
7443
|
if (!range) { endBurstWithoutResolve(); return true; }
|
|
7116
7444
|
let commitMd, commitStart, commitEnd;
|
|
7117
|
-
|
|
7445
|
+
// v3.11: a LOOSE run used to reach this branch through its 'P' items,
|
|
7446
|
+
// and a loose run holding a same-line nest ('- - b', an item with no
|
|
7447
|
+
// line of its own) still has to: re-serializing the whole run re-emits
|
|
7448
|
+
// that nest in its canonical multi-line form, which inside a loose list
|
|
7449
|
+
// changes the rendered nesting (measured: '- - - b' came back with an
|
|
7450
|
+
// escaped empty item). Committing only the edited item's lines keeps it.
|
|
7451
|
+
const looseWithNest = runEls.some((el) => blockOwnsNoLine(el)) &&
|
|
7452
|
+
runEls.some((el) => el.getAttribute('data-list-loose') === '1');
|
|
7453
|
+
if (unsupported.length > 0 || looseWithNest) {
|
|
7118
7454
|
// The run contains SOME unsupported content — commit only the edited
|
|
7119
7455
|
// li's own line range so nothing else in the run is round-tripped
|
|
7120
7456
|
// through the tight (blank-line-collapsed) runMd.
|
|
@@ -7222,7 +7558,7 @@
|
|
|
7222
7558
|
// `lines`), and return false so the caller (switchAwayFrom(), on
|
|
7223
7559
|
// behalf of whatever triggered this resolution) aborts instead of
|
|
7224
7560
|
// proceeding as if the burst resolved cleanly.
|
|
7225
|
-
|
|
7561
|
+
showNotice('含不支援的格式,改用原始碼編輯');
|
|
7226
7562
|
endBurstWithoutResolve();
|
|
7227
7563
|
openRawEditor(burst.blockEl);
|
|
7228
7564
|
return false;
|
|
@@ -7543,6 +7879,10 @@
|
|
|
7543
7879
|
}));
|
|
7544
7880
|
};
|
|
7545
7881
|
window.__edTestForceRerender = function () { return rerenderAll(); };
|
|
7882
|
+
// v3.10.0: a notice has no close button (it fades after 4s). Tests that only
|
|
7883
|
+
// need the banner gone before their next gesture take it down through the
|
|
7884
|
+
// same dismissBannerEl() the fade timer and the ✕ use.
|
|
7885
|
+
window.__edTestDismissBanner = function () { if (activeBanner) dismissBannerEl(activeBanner); };
|
|
7546
7886
|
window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
|
|
7547
7887
|
|
|
7548
7888
|
// v3.0.2: computeIndentClamp() lives past this file's node early-exit
|
|
@@ -7944,7 +8284,7 @@
|
|
|
7944
8284
|
async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
|
|
7945
8285
|
const span = runEls || [];
|
|
7946
8286
|
const presetRange = opts && opts.presetRange;
|
|
7947
|
-
const { md } =
|
|
8287
|
+
const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
|
|
7948
8288
|
// The run's line range is read back off its own li blocks' ids — which
|
|
7949
8289
|
// requires at least one li to still BE there. A caller whose mutation
|
|
7950
8290
|
// removed the run's last item therefore captures the range BEFORE mutating
|
|
@@ -8011,7 +8351,7 @@
|
|
|
8011
8351
|
// can emit a different number of lines than a re-serialized one would (a
|
|
8012
8352
|
// blank continuation is dropped on the re-serialize path but replayed
|
|
8013
8353
|
// verbatim here), and this answer is an index INTO those lines.
|
|
8014
|
-
const { lineMeta } =
|
|
8354
|
+
const { lineMeta } = serializeListEls(runEls, { carryOver: carryOver || null });
|
|
8015
8355
|
// A provisional block (split's new item) has no id yet, so it cannot be
|
|
8016
8356
|
// found by one — fall back to counting the lines emitted before it.
|
|
8017
8357
|
if (targetId === null) {
|
|
@@ -8020,7 +8360,16 @@
|
|
|
8020
8360
|
const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
|
|
8021
8361
|
let n = 0;
|
|
8022
8362
|
lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
|
|
8023
|
-
|
|
8363
|
+
// v3.11: a loose list's blank lines (lineMeta `blank`) sit between the
|
|
8364
|
+
// items counted above and this one; they are lines too.
|
|
8365
|
+
let k = 0;
|
|
8366
|
+
let seen = 0;
|
|
8367
|
+
while (k < lineMeta.length && seen < n) {
|
|
8368
|
+
if (before.indexOf(lineMeta[k].blockId) !== -1) seen++;
|
|
8369
|
+
k++;
|
|
8370
|
+
}
|
|
8371
|
+
while (k < lineMeta.length && lineMeta[k].blank) k++;
|
|
8372
|
+
return range.startLine + k;
|
|
8024
8373
|
}
|
|
8025
8374
|
const idx = lineMeta.findIndex((m) => m.blockId === targetId);
|
|
8026
8375
|
if (idx === -1) return null;
|
|
@@ -8107,13 +8456,21 @@
|
|
|
8107
8456
|
// excluding a burst whose surface has actually been edited.
|
|
8108
8457
|
function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
|
|
8109
8458
|
if (!runEls || !runEls.length) return false;
|
|
8110
|
-
const res =
|
|
8459
|
+
const res = serializeListEls(runEls);
|
|
8111
8460
|
const multi = res.multiLineBlockIds || [];
|
|
8112
8461
|
// Anything OTHER than MULTILINE still refuses run-wide, unchanged.
|
|
8113
8462
|
for (let i = 0; i < res.unsupported.length; i++) {
|
|
8114
8463
|
if (res.unsupported[i] !== 'MULTILINE') return false;
|
|
8115
8464
|
}
|
|
8116
8465
|
if (opts && opts.columnOnly) return true;
|
|
8466
|
+
// v3.11: `replaysTargets` — the operation never re-serializes a target's
|
|
8467
|
+
// CONTENT: it removes the target (delete), replays its source bytes
|
|
8468
|
+
// (duplicate's copy carries the original's id into bystanderCarryOver();
|
|
8469
|
+
// a li→li type change replays them with the marker's column delta), or
|
|
8470
|
+
// reads them straight from `lines` (li → non-list, convert-md.js's
|
|
8471
|
+
// stripMarker()). A multi-line target is then no different from a
|
|
8472
|
+
// multi-line bystander, which has always been fine.
|
|
8473
|
+
if (opts && opts.replaysTargets) return true;
|
|
8117
8474
|
// S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
|
|
8118
8475
|
// one run and every one of them has to clear §4.1's multi-line gate, not
|
|
8119
8476
|
// just the one the ⠿ was pressed on. An empty array is the same question as
|
|
@@ -9060,6 +9417,114 @@
|
|
|
9060
9417
|
// what makes 「段落 no-op」 a real no-op instead of a re-serialization that
|
|
9061
9418
|
// could move bytes nobody asked it to, and it is why this is ONE
|
|
9062
9419
|
// commitRangeEdit and therefore one undo op.
|
|
9420
|
+
// v3.11: Tab / Shift+Tab over a span mixing list items with other blocks,
|
|
9421
|
+
// or taking items of two lists. Each kind follows its own rule — items
|
|
9422
|
+
// indent (indentListItemsBySelection()'s headroom and clamp, per list),
|
|
9423
|
+
// headings change depth (changeHeadingDepthsInSpan()), paragraphs stay —
|
|
9424
|
+
// in ONE commit over the region the touched lists span, which is what the
|
|
9425
|
+
// old refusal said could not be done (two commits, two undo ops). Lists
|
|
9426
|
+
// that are adjacent blocks (a marker change between them, no gap) move as
|
|
9427
|
+
// one group, so an item of the second can land under an item of the first.
|
|
9428
|
+
async function tabSpanAcrossLists(els, recs, dir) {
|
|
9429
|
+
const isLi = (el) => el.getAttribute('data-block-type') === 'li';
|
|
9430
|
+
const all = allBlockEls();
|
|
9431
|
+
const groups = [];
|
|
9432
|
+
for (let i = 0; i < els.length; i++) {
|
|
9433
|
+
if (!isLi(els[i])) continue;
|
|
9434
|
+
let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
|
|
9435
|
+
if (!g) {
|
|
9436
|
+
const run = listRunOf(els[i]);
|
|
9437
|
+
if (!run.length) { refuseStructuralListEdit(); return; }
|
|
9438
|
+
const prev = groups[groups.length - 1];
|
|
9439
|
+
if (prev && all.indexOf(run[0]) === all.indexOf(prev.run[prev.run.length - 1]) + 1) {
|
|
9440
|
+
prev.run = prev.run.concat(run);
|
|
9441
|
+
g = prev;
|
|
9442
|
+
} else {
|
|
9443
|
+
g = { run: run, targets: [] };
|
|
9444
|
+
groups.push(g);
|
|
9445
|
+
}
|
|
9446
|
+
}
|
|
9447
|
+
g.targets.push(els[i]);
|
|
9448
|
+
}
|
|
9449
|
+
for (let i = 0; i < groups.length; i++) {
|
|
9450
|
+
const g = groups[i];
|
|
9451
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { columnOnly: true })) {
|
|
9452
|
+
refuseStructuralListEdit(); return;
|
|
9453
|
+
}
|
|
9454
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
9455
|
+
if (!g.range) return;
|
|
9456
|
+
}
|
|
9457
|
+
let lo = recs[0].startLine;
|
|
9458
|
+
let hi = recs[recs.length - 1].endLine;
|
|
9459
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
9460
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
9461
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
9462
|
+
const b = blocks[i];
|
|
9463
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
9464
|
+
const k = recs.indexOf(b);
|
|
9465
|
+
if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
|
|
9466
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
|
|
9467
|
+
}
|
|
9468
|
+
|
|
9469
|
+
let changed = false;
|
|
9470
|
+
groups.forEach((g) => {
|
|
9471
|
+
g.delta = batchIndentDelta(g.targets, dir);
|
|
9472
|
+
if (g.delta !== 0) changed = true;
|
|
9473
|
+
});
|
|
9474
|
+
// Segments in document order: each list group re-serialized, each other
|
|
9475
|
+
// member's own lines (a heading re-depthed). Whatever lies between two
|
|
9476
|
+
// segments — blank lines, by the check above — is copied as it is.
|
|
9477
|
+
const segs = [];
|
|
9478
|
+
for (let i = 0; i < recs.length; i++) {
|
|
9479
|
+
if (isLi(els[i])) continue;
|
|
9480
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
9481
|
+
if (recs[i].type === 'heading' && /^#{1,6}(\s|$)/.test(src[0])) {
|
|
9482
|
+
const depth = headingDepthOf(src[0]);
|
|
9483
|
+
const next = Math.max(1, Math.min(6, depth + dir));
|
|
9484
|
+
if (next !== depth) { src[0] = withHeadingDepth(src[0], next); changed = true; }
|
|
9485
|
+
}
|
|
9486
|
+
segs.push({ start: recs[i].startLine, end: recs[i].endLine, text: src });
|
|
9487
|
+
}
|
|
9488
|
+
if (!changed) return;
|
|
9489
|
+
mutateListRun(() => {
|
|
9490
|
+
groups.forEach((g) => {
|
|
9491
|
+
if (g.delta === 0) return;
|
|
9492
|
+
const oldIndent = spanMinIndent(g.targets);
|
|
9493
|
+
g.targets.forEach((el) => setBlockIndent(el,
|
|
9494
|
+
Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + g.delta)));
|
|
9495
|
+
applyIndentClamp(g.run, g.targets, oldIndent);
|
|
9496
|
+
});
|
|
9497
|
+
});
|
|
9498
|
+
const carry = bystanderCarryOver([].concat(...groups.map((g) => g.run)), null);
|
|
9499
|
+
groups.forEach((g) => {
|
|
9500
|
+
segs.push({ start: g.range.startLine, end: g.range.endLine,
|
|
9501
|
+
text: serializeListEls(g.run, { carryOver: carry }).md.split('\n') });
|
|
9502
|
+
});
|
|
9503
|
+
segs.sort((a, b) => a.start - b.start);
|
|
9504
|
+
const out = [];
|
|
9505
|
+
let at = lo;
|
|
9506
|
+
segs.forEach((sg) => {
|
|
9507
|
+
out.push(...lines.slice(at - 1, sg.start - 1));
|
|
9508
|
+
out.push(...sg.text);
|
|
9509
|
+
at = sg.end + 1;
|
|
9510
|
+
});
|
|
9511
|
+
out.push(...lines.slice(at - 1, hi));
|
|
9512
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
|
|
9513
|
+
if (result.op === null) return;
|
|
9514
|
+
endBurstWithoutResolve();
|
|
9515
|
+
const prevLines = lines;
|
|
9516
|
+
lines = result.lines;
|
|
9517
|
+
declareCollapse({ startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine });
|
|
9518
|
+
const editRange = result.op
|
|
9519
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
9520
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
9521
|
+
delta: result.op.after.length - result.op.before.length }
|
|
9522
|
+
: undefined;
|
|
9523
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
9524
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
9525
|
+
}
|
|
9526
|
+
}
|
|
9527
|
+
|
|
9063
9528
|
async function changeHeadingDepthsInSpan(els, recs, dir) {
|
|
9064
9529
|
const first = recs[0];
|
|
9065
9530
|
const last = recs[recs.length - 1];
|
|
@@ -9117,13 +9582,14 @@
|
|
|
9117
9582
|
const els = operands.els;
|
|
9118
9583
|
const recs = operands.recs;
|
|
9119
9584
|
const shape = spanListKinds(els);
|
|
9120
|
-
//
|
|
9121
|
-
//
|
|
9122
|
-
//
|
|
9123
|
-
//
|
|
9124
|
-
//
|
|
9125
|
-
|
|
9126
|
-
|
|
9585
|
+
// v3.11: a span mixing both kinds applies each rule to its own kind (the
|
|
9586
|
+
// S3 plan's Task 7 text) in ONE commit — tabSpanAcrossLists() — so §3.4's
|
|
9587
|
+
// 「一次使用者手勢 = 恰好一個 undo op」 holds; so does a span taking items of
|
|
9588
|
+
// two lists. Both used to refuse (§3.6's 2026-08-31 ruling, which only
|
|
9589
|
+
// stood because the combined commit did not exist).
|
|
9590
|
+
if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
|
|
9591
|
+
await tabSpanAcrossLists(els, recs, dir); return;
|
|
9592
|
+
}
|
|
9127
9593
|
if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
|
|
9128
9594
|
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
9129
9595
|
}
|
|
@@ -9222,7 +9688,7 @@
|
|
|
9222
9688
|
// and shadowed each other silently (last-declaration-wins), so the
|
|
9223
9689
|
// no-source-line callers pass their own text instead of a second function.
|
|
9224
9690
|
function refuseStructuralListEdit(message) {
|
|
9225
|
-
|
|
9691
|
+
showNotice(message || '此清單含不支援的格式,無法調整結構');
|
|
9226
9692
|
// Review recommendation 5: mark it, so the next SUCCESSFUL render clears
|
|
9227
9693
|
// it (dismissRefusalBanner(), called from rerenderAll()). Set after the
|
|
9228
9694
|
// showBanner() call, which resets the flag for every other caller.
|
|
@@ -10310,7 +10776,7 @@
|
|
|
10310
10776
|
const burst = currentBurst;
|
|
10311
10777
|
if (!burst || burst.blockType !== 'table' || burst.editEl !== tableEl || !grid.length) return false;
|
|
10312
10778
|
const width = grid.reduce((w, row) => Math.max(w, row.length), 0);
|
|
10313
|
-
if (grid.length > 500 || width > 50) {
|
|
10779
|
+
if (grid.length > 500 || width > 50) { showNotice(GRID_PASTE_TOO_BIG_MESSAGE); return true; }
|
|
10314
10780
|
const origin = burst.cellRange
|
|
10315
10781
|
? (() => { const rc = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
|
|
10316
10782
|
return { r: rc.top, c: rc.left }; })()
|
|
@@ -10423,7 +10889,7 @@
|
|
|
10423
10889
|
const liveSrc = srcRec
|
|
10424
10890
|
? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
|
|
10425
10891
|
dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
|
|
10426
|
-
if (!dest) {
|
|
10892
|
+
if (!dest) { showError(DROPPED_GESTURE_MESSAGE); return; }
|
|
10427
10893
|
}
|
|
10428
10894
|
if (x !== null) arrowGoalX = x;
|
|
10429
10895
|
arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
|
|
@@ -10991,7 +11457,7 @@
|
|
|
10991
11457
|
// Dropping is still the right answer (see the S1 comment above); saying
|
|
10992
11458
|
// nothing was not.
|
|
10993
11459
|
if (!liveTableEl) {
|
|
10994
|
-
|
|
11460
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null); return null;
|
|
10995
11461
|
}
|
|
10996
11462
|
}
|
|
10997
11463
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
|
|
@@ -11059,7 +11525,7 @@
|
|
|
11059
11525
|
// 15 call sites): can't relocate it on the live table -> drop the
|
|
11060
11526
|
// gesture and say so, never guess.
|
|
11061
11527
|
if (!targetCell) {
|
|
11062
|
-
|
|
11528
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
11063
11529
|
return;
|
|
11064
11530
|
}
|
|
11065
11531
|
currentBurst.history.snap('insert-col-pre');
|
|
@@ -11111,7 +11577,7 @@
|
|
|
11111
11577
|
// can't relocate it on the live table -> drop the gesture and say so,
|
|
11112
11578
|
// never guess.
|
|
11113
11579
|
if (afterRowIndex >= 0 && !afterRow) {
|
|
11114
|
-
|
|
11580
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
11115
11581
|
return;
|
|
11116
11582
|
}
|
|
11117
11583
|
currentBurst.history.snap('insert-row-pre');
|
|
@@ -11587,7 +12053,7 @@
|
|
|
11587
12053
|
if (!liveTableEl) return;
|
|
11588
12054
|
const headerRow = headerRowOf(liveTableEl);
|
|
11589
12055
|
if (!headerRow || headerRow.cells.length <= 1) {
|
|
11590
|
-
|
|
12056
|
+
showNotice('這是表格的最後一欄。要刪除整張表格,請用 ⠿ → 刪除。');
|
|
11591
12057
|
return;
|
|
11592
12058
|
}
|
|
11593
12059
|
currentBurst.history.snap('delete-col-pre');
|
|
@@ -11614,12 +12080,28 @@
|
|
|
11614
12080
|
if (!liveTableEl) return;
|
|
11615
12081
|
const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
|
|
11616
12082
|
if (!liveRowEl) return;
|
|
12083
|
+
// v3.11 (Notion does what the gesture says): deleting the header row
|
|
12084
|
+
// promotes the first body row into it; with no body rows left the table
|
|
12085
|
+
// has nothing to keep, so the whole table block goes. The last BODY row
|
|
12086
|
+
// goes like any other — a header-only table is valid GFM.
|
|
11617
12087
|
if (liveRowEl === headerRowOf(liveTableEl)) {
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
12088
|
+
const firstBody = bodyRowsOf(liveTableEl)[0];
|
|
12089
|
+
if (!firstBody) {
|
|
12090
|
+
hideTableEdgeMenu();
|
|
12091
|
+
const tableBlockEl = liveTableEl.closest('.ed-block');
|
|
12092
|
+
if (tableBlockEl) await deleteBlockViaGutter(tableBlockEl);
|
|
12093
|
+
return;
|
|
12094
|
+
}
|
|
12095
|
+
currentBurst.history.snap('delete-header-pre');
|
|
12096
|
+
const head = liveRowEl.cells;
|
|
12097
|
+
for (let i = 0; i < head.length; i++) {
|
|
12098
|
+
const src = firstBody.cells[i];
|
|
12099
|
+
head[i].innerHTML = src ? src.innerHTML : '';
|
|
12100
|
+
}
|
|
12101
|
+
refocusAwayFromRow(liveTableEl, firstBody);
|
|
12102
|
+
deleteRow(firstBody);
|
|
12103
|
+
currentBurst.history.snap('delete-header');
|
|
12104
|
+
hideTableEdgeMenu();
|
|
11623
12105
|
return;
|
|
11624
12106
|
}
|
|
11625
12107
|
currentBurst.history.snap('delete-row-pre');
|
|
@@ -12158,12 +12640,10 @@
|
|
|
12158
12640
|
// v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
|
|
12159
12641
|
// buildTableGrip() for the S4 T1 detour into an inset grip and how it
|
|
12160
12642
|
// was undone), vertically centred on its own row; no
|
|
12161
|
-
// header special case.
|
|
12162
|
-
//
|
|
12163
|
-
//
|
|
12164
|
-
|
|
12165
|
-
// from the page. Withhold the grip there instead.
|
|
12166
|
-
if (rowEl && (rowEl !== headerRow || bodyRowsOf(tableEl).length > 0)) {
|
|
12643
|
+
// header special case. v3.11: that includes a header-only table — its
|
|
12644
|
+
// grip opens the row menu (刪除列 there removes the table); the DRAG is
|
|
12645
|
+
// what must not empty the thead, and performRowDrop() refuses it.
|
|
12646
|
+
if (rowEl) {
|
|
12167
12647
|
gripRowTableEl = tableEl;
|
|
12168
12648
|
gripRowEl = rowEl;
|
|
12169
12649
|
const r = rowEl.getBoundingClientRect();
|
|
@@ -12485,6 +12965,8 @@
|
|
|
12485
12965
|
// land on the header cell.
|
|
12486
12966
|
const liveTableEl = await ensureTableBurstOpen(tableEl, true);
|
|
12487
12967
|
if (!liveTableEl) return;
|
|
12968
|
+
// A header-only table has nowhere to move its one row to.
|
|
12969
|
+
if (!bodyRowsOf(liveTableEl).length) return;
|
|
12488
12970
|
const liveRows = allRowsOf(liveTableEl);
|
|
12489
12971
|
if (rowIndex < 0 || !liveRows[rowIndex]) return;
|
|
12490
12972
|
|
|
@@ -12936,7 +13418,7 @@
|
|
|
12936
13418
|
const sourceEl = els[st.sourceIndex];
|
|
12937
13419
|
const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
|
|
12938
13420
|
if (!sourceEl || (target.mode !== 'append' && !destEl)) {
|
|
12939
|
-
|
|
13421
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
12940
13422
|
return;
|
|
12941
13423
|
}
|
|
12942
13424
|
const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
|
|
@@ -12970,7 +13452,7 @@
|
|
|
12970
13452
|
if (destEl && !document.body.contains(destEl)) {
|
|
12971
13453
|
liveDestEl = reresolveBlockEl(destIdentity) ||
|
|
12972
13454
|
(destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
|
|
12973
|
-
if (!liveDestEl) {
|
|
13455
|
+
if (!liveDestEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
12974
13456
|
}
|
|
12975
13457
|
// ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
|
|
12976
13458
|
// §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
|
|
@@ -12998,23 +13480,12 @@
|
|
|
12998
13480
|
// SECOND home position of its own — see performListItemDrop().
|
|
12999
13481
|
if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
|
|
13000
13482
|
const shape = spanListKinds(opEls);
|
|
13001
|
-
//
|
|
13002
|
-
//
|
|
13003
|
-
//
|
|
13004
|
-
//
|
|
13005
|
-
//
|
|
13006
|
-
|
|
13007
|
-
// 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
|
|
13008
|
-
//
|
|
13009
|
-
// DELIBERATELY ABOVE the second home position, which is the one place this
|
|
13010
|
-
// function departs from Task 3's "answer a home position before every
|
|
13011
|
-
// gate" rule, and the departure is the point: a seam objection depends on
|
|
13012
|
-
// where the user AIMED, so refusing a put-it-back gesture with one would
|
|
13013
|
-
// be a banner for a gesture in which nothing went wrong. MIXED is a
|
|
13014
|
-
// property of the SET — it gives the same answer at every destination, so
|
|
13015
|
-
// there is no aim that would have worked and saying so is the honest
|
|
13016
|
-
// outcome. (The drop-on-a-member case is already out, above.)
|
|
13017
|
-
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
13483
|
+
// v3.11: a span mixing list items with other blocks moves too. It used to
|
|
13484
|
+
// refuse (§3.6's 2026-08-31 ruling: the blank-line rule at the run/non-run
|
|
13485
|
+
// seam had no answer). moveSpanAcrossLists() gives it one — list items in
|
|
13486
|
+
// a row are serialized as one tight list, everything else keeps its own
|
|
13487
|
+
// separators or gets one blank line — so the set has a destination.
|
|
13488
|
+
if (shape.anyLi && !shape.allLi) { await moveSpanAcrossLists(opEls, liveDestEl); return; }
|
|
13018
13489
|
// S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
|
|
13019
13490
|
// relocated verbatim (markers and ordinals are run-global), so the whole
|
|
13020
13491
|
// run is re-serialized in the new span order instead — still ONE
|
|
@@ -13039,7 +13510,7 @@
|
|
|
13039
13510
|
const sj = live.indexOf(opEls[opEls.length - 1]);
|
|
13040
13511
|
const rest = live.filter((el) => opEls.indexOf(el) === -1);
|
|
13041
13512
|
const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
|
|
13042
|
-
if (si < 0 || sj < 0 || di < 0) {
|
|
13513
|
+
if (si < 0 || sj < 0 || di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13043
13514
|
// §3.3's set is contiguous in block order — resolveGutterOperands()'s own
|
|
13044
13515
|
// gate — so its members' lines plus the separators between them are ONE
|
|
13045
13516
|
// range, which is what makes a batch move the same single commitRangeEdit
|
|
@@ -13047,7 +13518,7 @@
|
|
|
13047
13518
|
const srcRec = spanMoveRange(opRecs);
|
|
13048
13519
|
const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
|
|
13049
13520
|
if (!srcRec || (liveDestEl && !destRec)) {
|
|
13050
|
-
|
|
13521
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
13051
13522
|
return;
|
|
13052
13523
|
}
|
|
13053
13524
|
// A destination that owns no source line has an INVERTED range, and its
|
|
@@ -13114,12 +13585,11 @@
|
|
|
13114
13585
|
const seamRefusal = blockMoveSeamRefusal(
|
|
13115
13586
|
{ prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
|
|
13116
13587
|
{ prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
|
|
13117
|
-
|
|
13118
|
-
|
|
13119
|
-
|
|
13120
|
-
|
|
13121
|
-
|
|
13122
|
-
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
13588
|
+
// v3.11: neither seam refuses any more. Two lists that meet once this
|
|
13589
|
+
// block has left join; a block dropped between two items splits the list.
|
|
13590
|
+
// Both need list items re-serialized, so they take moveSpanAcrossLists().
|
|
13591
|
+
if (seamRefusal === 'source' || seamRefusal === 'destination') {
|
|
13592
|
+
await moveSpanAcrossLists(opEls, liveDestEl);
|
|
13123
13593
|
return;
|
|
13124
13594
|
}
|
|
13125
13595
|
const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
|
|
@@ -13206,6 +13676,191 @@
|
|
|
13206
13676
|
// contiguous slice of the document (listRunOf()'s own contract), so the
|
|
13207
13677
|
// members occupy CONSECUTIVE slots of `run` and "move them" is one call to
|
|
13208
13678
|
// reorderSpanRange() with a count instead of two paths.
|
|
13679
|
+
// v3.11: the ⠿ drop the narrower paths used to refuse — a paragraph into
|
|
13680
|
+
// the middle of a list (it splits), a list item out of its list or into
|
|
13681
|
+
// another one (it leaves / joins), a block whose removal makes two lists
|
|
13682
|
+
// meet (they join), a span mixing list items with other blocks, or items
|
|
13683
|
+
// of two lists. A list item takes its subtree along, as in Notion.
|
|
13684
|
+
//
|
|
13685
|
+
// ONE commit over the region the move touches: the moved span, the blocks
|
|
13686
|
+
// on either side of where it left and where it lands, and every list any of
|
|
13687
|
+
// those belongs to, whole. Inside it the blocks are laid out in their new
|
|
13688
|
+
// order; each maximal run of consecutive list items is re-serialized as one
|
|
13689
|
+
// list (serializeBlocks(), bystander bytes replayed, indents clamped so no
|
|
13690
|
+
// item is more than one level below the one above it), every other block
|
|
13691
|
+
// is its own source lines. Two blocks that were already neighbours keep the
|
|
13692
|
+
// lines that separated them; new neighbours get one blank line.
|
|
13693
|
+
async function moveSpanAcrossLists(opEls, destEl) {
|
|
13694
|
+
const all = allBlockEls();
|
|
13695
|
+
const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
|
|
13696
|
+
const indentOf = (el) => Number(el.getAttribute('data-indent')) || 0;
|
|
13697
|
+
const opLi = opEls.filter(isLi);
|
|
13698
|
+
const minLi = opLi.length ? spanMinIndent(opLi) : 0;
|
|
13699
|
+
const moving = opEls.slice();
|
|
13700
|
+
if (opLi.length) {
|
|
13701
|
+
subtreeBlocksAfter(moving[moving.length - 1], minLi).forEach((el) => {
|
|
13702
|
+
if (moving.indexOf(el) === -1) moving.push(el);
|
|
13703
|
+
});
|
|
13704
|
+
}
|
|
13705
|
+
const firstIdx = all.indexOf(moving[0]);
|
|
13706
|
+
const lastIdx = all.indexOf(moving[moving.length - 1]);
|
|
13707
|
+
if (firstIdx < 0 || lastIdx - firstIdx + 1 !== moving.length) {
|
|
13708
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null); return;
|
|
13709
|
+
}
|
|
13710
|
+
if (destEl && moving.indexOf(destEl) !== -1) return;
|
|
13711
|
+
const rest = all.filter((el) => moving.indexOf(el) === -1);
|
|
13712
|
+
const di = destEl ? rest.indexOf(destEl) : rest.length;
|
|
13713
|
+
if (di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13714
|
+
if (di === firstIdx) return; // dropped where it already is
|
|
13715
|
+
|
|
13716
|
+
// The region: indices into `all`, widened until every list it touches is
|
|
13717
|
+
// inside it whole.
|
|
13718
|
+
const touched = moving.concat([all[firstIdx - 1], all[lastIdx + 1], rest[di - 1], rest[di]])
|
|
13719
|
+
.filter(Boolean);
|
|
13720
|
+
let lo = Math.min(...touched.map((el) => all.indexOf(el)));
|
|
13721
|
+
let hi = Math.max(...touched.map((el) => all.indexOf(el)));
|
|
13722
|
+
for (let grew = true; grew;) {
|
|
13723
|
+
grew = false;
|
|
13724
|
+
for (let k = lo; k <= hi; k++) {
|
|
13725
|
+
if (!isLi(all[k])) continue;
|
|
13726
|
+
const run = listRunOf(all[k]);
|
|
13727
|
+
if (!run.length) continue;
|
|
13728
|
+
const a = all.indexOf(run[0]);
|
|
13729
|
+
const b = all.indexOf(run[run.length - 1]);
|
|
13730
|
+
if (a < lo) { lo = a; grew = true; }
|
|
13731
|
+
if (b > hi) { hi = b; grew = true; }
|
|
13732
|
+
}
|
|
13733
|
+
}
|
|
13734
|
+
const regionEls = all.slice(lo, hi + 1);
|
|
13735
|
+
for (let k = 0; k < regionEls.length; k++) {
|
|
13736
|
+
if (blockOwnsNoLine(regionEls[k])) { refuseStructuralListEdit(noSourceLineMessageFor(regionEls[k])); return; }
|
|
13737
|
+
if (!blockRecOf(regionEls[k])) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13738
|
+
}
|
|
13739
|
+
const seenRuns = [];
|
|
13740
|
+
const runOf = new Map(); // each item's list BEFORE the move
|
|
13741
|
+
for (let k = 0; k < regionEls.length; k++) {
|
|
13742
|
+
if (!isLi(regionEls[k]) || runOf.has(regionEls[k])) continue;
|
|
13743
|
+
const run = listRunOf(regionEls[k]);
|
|
13744
|
+
seenRuns.push(run);
|
|
13745
|
+
run.forEach((el) => runOf.set(el, run));
|
|
13746
|
+
if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) { refuseStructuralListEdit(); return; }
|
|
13747
|
+
}
|
|
13748
|
+
|
|
13749
|
+
// The new order.
|
|
13750
|
+
const restRegion = regionEls.filter((el) => moving.indexOf(el) === -1);
|
|
13751
|
+
const at = destEl ? restRegion.indexOf(destEl) : restRegion.length;
|
|
13752
|
+
if (at < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13753
|
+
const seq = restRegion.slice(0, at).concat(moving, restRegion.slice(at));
|
|
13754
|
+
// Indents: a moved item lands at the level of the item it is dropped in
|
|
13755
|
+
// front of (top level otherwise), keeping its own subtree's shape; then
|
|
13756
|
+
// every item is clamped to at most one level below the item above it.
|
|
13757
|
+
const base = isLi(destEl) ? indentOf(destEl) : 0;
|
|
13758
|
+
const want = new Map();
|
|
13759
|
+
let prev = -1;
|
|
13760
|
+
seq.forEach((el) => {
|
|
13761
|
+
if (isLi(el)) {
|
|
13762
|
+
let ind = moving.indexOf(el) !== -1 ? base + (indentOf(el) - minLi) : indentOf(el);
|
|
13763
|
+
ind = Math.max(0, Math.min(ind, prev + 1));
|
|
13764
|
+
want.set(el, ind);
|
|
13765
|
+
prev = ind;
|
|
13766
|
+
} else {
|
|
13767
|
+
prev = -1;
|
|
13768
|
+
}
|
|
13769
|
+
});
|
|
13770
|
+
const firstRec = blockRecOf(regionEls[0]);
|
|
13771
|
+
const lastRec = blockRecOf(regionEls[regionEls.length - 1]);
|
|
13772
|
+
const carry = bystanderCarryOver(seq.filter(isLi));
|
|
13773
|
+
const origIndent = new Map(seq.filter(isLi).map((el) => [el, indentOf(el)]));
|
|
13774
|
+
mutateListRun(() => {
|
|
13775
|
+
seq.forEach((el, k) => {
|
|
13776
|
+
if (isLi(el)) {
|
|
13777
|
+
if (want.get(el) !== indentOf(el)) setBlockIndent(el, want.get(el));
|
|
13778
|
+
// A list boundary survives only where the same two blocks still meet.
|
|
13779
|
+
const before = seq[k - 1];
|
|
13780
|
+
if (isLi(before) && all[all.indexOf(el) - 1] !== before) el.removeAttribute('data-list-start');
|
|
13781
|
+
}
|
|
13782
|
+
});
|
|
13783
|
+
});
|
|
13784
|
+
|
|
13785
|
+
const out = [];
|
|
13786
|
+
const movedIds = moving.map((el) => el.getAttribute('data-block-id'));
|
|
13787
|
+
let movedFrom = null;
|
|
13788
|
+
let movedTo = null;
|
|
13789
|
+
const mark = (id, idx) => {
|
|
13790
|
+
if (movedIds.indexOf(id) !== -1) {
|
|
13791
|
+
if (movedFrom === null || idx < movedFrom) movedFrom = idx;
|
|
13792
|
+
if (movedTo === null || idx > movedTo) movedTo = idx;
|
|
13793
|
+
}
|
|
13794
|
+
};
|
|
13795
|
+
// Two items that were already neighbours in two DIFFERENT lists stay two
|
|
13796
|
+
// lists, with whatever separated them.
|
|
13797
|
+
const keepsApart = (x, y) => all[all.indexOf(x) + 1] === y && runOf.get(x) !== runOf.get(y);
|
|
13798
|
+
let lastEl = null;
|
|
13799
|
+
for (let k = 0; k < seq.length;) {
|
|
13800
|
+
let unit;
|
|
13801
|
+
let unitLines;
|
|
13802
|
+
let meta = null;
|
|
13803
|
+
if (isLi(seq[k])) {
|
|
13804
|
+
let j = k + 1;
|
|
13805
|
+
while (j < seq.length && isLi(seq[j]) && !keepsApart(seq[j - 1], seq[j])) j++;
|
|
13806
|
+
unit = seq.slice(k, j);
|
|
13807
|
+
const orig = runOf.get(unit[0]);
|
|
13808
|
+
if (orig && orig.length === unit.length && orig.every((el, n) => el === unit[n] &&
|
|
13809
|
+
moving.indexOf(el) === -1 && want.get(el) === origIndent.get(el))) {
|
|
13810
|
+
// A list the move did not touch keeps its own bytes.
|
|
13811
|
+
const r0 = blockRecOf(unit[0]);
|
|
13812
|
+
const r1 = blockRecOf(unit[unit.length - 1]);
|
|
13813
|
+
unitLines = lines.slice(r0.startLine - 1, r1.endLine);
|
|
13814
|
+
} else {
|
|
13815
|
+
const res = serializeListEls(unit, { carryOver: carry });
|
|
13816
|
+
unitLines = res.md.split('\n');
|
|
13817
|
+
meta = res.lineMeta;
|
|
13818
|
+
}
|
|
13819
|
+
k = j;
|
|
13820
|
+
} else {
|
|
13821
|
+
unit = [seq[k]];
|
|
13822
|
+
const r = blockRecOf(seq[k]);
|
|
13823
|
+
unitLines = lines.slice(r.startLine - 1, r.endLine);
|
|
13824
|
+
k++;
|
|
13825
|
+
}
|
|
13826
|
+
if (lastEl) {
|
|
13827
|
+
const a = blockRecOf(lastEl);
|
|
13828
|
+
const b = blockRecOf(unit[0]);
|
|
13829
|
+
if (all[all.indexOf(lastEl) + 1] === unit[0]) out.push(...lines.slice(a.endLine, b.startLine - 1));
|
|
13830
|
+
else out.push('');
|
|
13831
|
+
}
|
|
13832
|
+
const base0 = out.length;
|
|
13833
|
+
unitLines.forEach((l, n) => {
|
|
13834
|
+
mark(meta ? (meta[n] && meta[n].blockId) : unit[0].getAttribute('data-block-id'), base0 + n);
|
|
13835
|
+
});
|
|
13836
|
+
out.push(...unitLines);
|
|
13837
|
+
lastEl = unit[unit.length - 1];
|
|
13838
|
+
}
|
|
13839
|
+
let lead = 0;
|
|
13840
|
+
if (seq[0] !== regionEls[0] && firstRec.startLine > 1 &&
|
|
13841
|
+
String(lines[firstRec.startLine - 2]).trim() !== '') { out.unshift(''); lead = 1; }
|
|
13842
|
+
if (seq[seq.length - 1] !== regionEls[regionEls.length - 1] && lastRec.endLine < lines.length &&
|
|
13843
|
+
String(lines[lastRec.endLine]).trim() !== '') out.push('');
|
|
13844
|
+
|
|
13845
|
+
const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'));
|
|
13846
|
+
if (result.op === null) return;
|
|
13847
|
+
endBurstWithoutResolve();
|
|
13848
|
+
const prevLines = lines;
|
|
13849
|
+
lines = result.lines;
|
|
13850
|
+
if (movedFrom !== null) {
|
|
13851
|
+
declareCollapse({ startLine: firstRec.startLine + lead + movedFrom,
|
|
13852
|
+
endLine: firstRec.startLine + lead + movedTo });
|
|
13853
|
+
}
|
|
13854
|
+
const editRange = result.op
|
|
13855
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
13856
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
13857
|
+
delta: result.op.after.length - result.op.before.length }
|
|
13858
|
+
: undefined;
|
|
13859
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
13860
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
13861
|
+
}
|
|
13862
|
+
}
|
|
13863
|
+
|
|
13209
13864
|
async function performListItemDrop(liEls, destEl) {
|
|
13210
13865
|
const liEl = liEls[0];
|
|
13211
13866
|
const lastLiEl = liEls[liEls.length - 1];
|
|
@@ -13217,7 +13872,7 @@
|
|
|
13217
13872
|
const run = listRunOf(liEl);
|
|
13218
13873
|
const srcIdxs = liEls.map((el) => run.indexOf(el));
|
|
13219
13874
|
const srcIdx = srcIdxs[0];
|
|
13220
|
-
if (srcIdx < 0) {
|
|
13875
|
+
if (srcIdx < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13221
13876
|
// A member outside the grip's own commit span means the set straddles TWO
|
|
13222
13877
|
// runs — contiguity in `blocks` does not imply one run, because two
|
|
13223
13878
|
// adjacent runs separated by a delimiter change ('- a' / '1. b') are
|
|
@@ -13225,7 +13880,8 @@
|
|
|
13225
13880
|
// convertBlockViaMenu() gives, through the same message: the commit
|
|
13226
13881
|
// re-serializes ONE run's line range and a member outside it would be
|
|
13227
13882
|
// rewritten by a range that does not cover it.
|
|
13228
|
-
|
|
13883
|
+
// v3.11: items of two lists move together through moveSpanAcrossLists().
|
|
13884
|
+
if (srcIdxs.indexOf(-1) !== -1) { await moveSpanAcrossLists(liEls, destEl); return; }
|
|
13229
13885
|
// ...and they must be CONSECUTIVE slots of it. Guaranteed today by
|
|
13230
13886
|
// spanIsContiguous() plus listRunOf() returning a contiguous slice, and
|
|
13231
13887
|
// checked anyway rather than asserted in prose: reorderSpanRange() takes a
|
|
@@ -13294,7 +13950,9 @@
|
|
|
13294
13950
|
// {mode:'append'} target when a block follows the run. Task 5 gave them
|
|
13295
13951
|
// their own message — the remedy is "stay inside your own list", which
|
|
13296
13952
|
// neither seam message says.
|
|
13297
|
-
|
|
13953
|
+
// v3.11: out of its own list — into another list, or anywhere else —
|
|
13954
|
+
// used to refuse with BLOCK_MOVE_OUT_OF_RUN_MESSAGE.
|
|
13955
|
+
if (insertAt < 0) { await moveSpanAcrossLists(liEls, destEl); return; }
|
|
13298
13956
|
// `null` is the order-is-unchanged answer — the two home positions above
|
|
13299
13957
|
// reach this by another road (an item whose next block is a sibling), and
|
|
13300
13958
|
// it stays SILENT for the same reason.
|
|
@@ -13305,7 +13963,7 @@
|
|
|
13305
13963
|
// the ARRAY, and the reordered span's first member is not the run's first
|
|
13306
13964
|
// line.
|
|
13307
13965
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
13308
|
-
if (!range) {
|
|
13966
|
+
if (!range) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13309
13967
|
const span = order.map((i) => run[i]);
|
|
13310
13968
|
// ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
|
|
13311
13969
|
//
|
|
@@ -13357,7 +14015,9 @@
|
|
|
13357
14015
|
indent: clamped.has(el) ? clamped.get(el)
|
|
13358
14016
|
: (Number(el.getAttribute('data-indent')) || 0),
|
|
13359
14017
|
})))) {
|
|
13360
|
-
|
|
14018
|
+
// v3.11: the generic move re-anchors instead of refusing (its indent
|
|
14019
|
+
// clamp keeps every item within one level of the item above it).
|
|
14020
|
+
await moveSpanAcrossLists(liEls, destEl);
|
|
13361
14021
|
return;
|
|
13362
14022
|
}
|
|
13363
14023
|
// S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
|
|
@@ -13437,7 +14097,7 @@
|
|
|
13437
14097
|
function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
|
|
13438
14098
|
const ids = liEls.map((el) => el.getAttribute('data-block-id'));
|
|
13439
14099
|
if (ids.indexOf(null) !== -1) return null;
|
|
13440
|
-
const { lineMeta } =
|
|
14100
|
+
const { lineMeta } = serializeListEls(span, { carryOver: carryOver || null });
|
|
13441
14101
|
let first = -1;
|
|
13442
14102
|
let last = -1;
|
|
13443
14103
|
lineMeta.forEach((m, i) => {
|
|
@@ -13662,17 +14322,10 @@
|
|
|
13662
14322
|
}
|
|
13663
14323
|
// A plain press-release with no drag threshold crossed: open the menu
|
|
13664
14324
|
// for whatever zone was hit at pointerdown.
|
|
14325
|
+
// v3.11: the header row's grip opens the same menu — deleting the header
|
|
14326
|
+
// promotes the first body row (runDeleteRow()).
|
|
13665
14327
|
if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
|
|
13666
|
-
else
|
|
13667
|
-
// The row menu's only item is "delete row", and the header row can
|
|
13668
|
-
// never be deleted — showing it would just be an empty box. A plain
|
|
13669
|
-
// click on the header grip highlights the row instead.
|
|
13670
|
-
// clearEdgeHighlight() runs first so re-clicking a different header
|
|
13671
|
-
// (or a different row's menu having been open) doesn't stack
|
|
13672
|
-
// highlights within the same session.
|
|
13673
|
-
clearEdgeHighlight();
|
|
13674
|
-
highlightRow(st.hit.rowEl);
|
|
13675
|
-
} else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
14328
|
+
else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
13676
14329
|
});
|
|
13677
14330
|
|
|
13678
14331
|
// Review fix (Critical): the browser/OS can ABORT a gesture outright —
|
|
@@ -14319,6 +14972,11 @@
|
|
|
14319
14972
|
// paintSelToolbarMarks() below. Filled by buildSelToolbar(), which runs once.
|
|
14320
14973
|
const selToolbarMarkBtns = [];
|
|
14321
14974
|
|
|
14975
|
+
const SELTB_ICON = {
|
|
14976
|
+
'ed-seltb-b': 'bold', 'ed-seltb-i': 'italic', 'ed-seltb-s': 'strikethrough',
|
|
14977
|
+
'ed-seltb-u': 'underline', 'ed-seltb-code': 'code', 'ed-seltb-link': 'link',
|
|
14978
|
+
};
|
|
14979
|
+
|
|
14322
14980
|
function buildSelToolbar() {
|
|
14323
14981
|
const el = document.createElement('div');
|
|
14324
14982
|
el.className = 'ed-seltb';
|
|
@@ -14326,7 +14984,7 @@
|
|
|
14326
14984
|
const b = document.createElement('button');
|
|
14327
14985
|
b.type = 'button';
|
|
14328
14986
|
b.className = 'ed-seltb-btn ' + cls;
|
|
14329
|
-
b.
|
|
14987
|
+
b.innerHTML = icons.svg(SELTB_ICON[cls], 16);
|
|
14330
14988
|
b.setAttribute('aria-label', ariaLabel);
|
|
14331
14989
|
b.setAttribute('aria-pressed', 'false');
|
|
14332
14990
|
selToolbarMarkBtns.push({ btn: b, tag: tag });
|
|
@@ -14344,6 +15002,10 @@
|
|
|
14344
15002
|
addBtn('ed-seltb-s', 'S', '刪除線', 'DEL', () => applyMarkToggle('DEL'));
|
|
14345
15003
|
addBtn('ed-seltb-u', 'U', '底線', 'U', () => applyMarkToggle('U'));
|
|
14346
15004
|
addBtn('ed-seltb-code', '<>', 'Code', 'CODE', () => applyMarkToggle('CODE'));
|
|
15005
|
+
const sep = document.createElement('span');
|
|
15006
|
+
sep.className = 'ed-seltb-sep';
|
|
15007
|
+
sep.setAttribute('aria-hidden', 'true');
|
|
15008
|
+
el.appendChild(sep);
|
|
14347
15009
|
addBtn('ed-seltb-link', '\u{1F517}', 'Link', 'A', () => applyLinkToggle());
|
|
14348
15010
|
return el;
|
|
14349
15011
|
}
|
|
@@ -14713,7 +15375,24 @@
|
|
|
14713
15375
|
}
|
|
14714
15376
|
|
|
14715
15377
|
// ── save ───────────────────────────────────────────────────────────────
|
|
15378
|
+
// v3.10.0: the outside-edit check (onDiskMtime() below) must not read its
|
|
15379
|
+
// own saves as someone else's edit. A ping answered while a save is in
|
|
15380
|
+
// flight, or one sent before a save that has since landed, carries an mtime
|
|
15381
|
+
// that is not comparable with `mtimeMs`; saveGen / savesInFlight let the
|
|
15382
|
+
// ping tell (no save in flight when it was sent, none started or in flight
|
|
15383
|
+
// when it came back). Reasoned from the ordering, not measured — both directions: a
|
|
15384
|
+
// stale pre-save mtime
|
|
15385
|
+
// against the post-save baseline would reload a clean page for nothing, and
|
|
15386
|
+
// a post-write mtime read before the save's reply lands would raise the
|
|
15387
|
+
// conflict card over the user's own save.
|
|
15388
|
+
let saveGen = 0;
|
|
15389
|
+
let savesInFlight = 0;
|
|
14716
15390
|
async function save() {
|
|
15391
|
+
saveGen++;
|
|
15392
|
+
savesInFlight++;
|
|
15393
|
+
try { return await sendSave(); } finally { savesInFlight--; }
|
|
15394
|
+
}
|
|
15395
|
+
async function sendSave() {
|
|
14717
15396
|
// fix 3: the receipt for the bytes about to leave, taken BEFORE the request
|
|
14718
15397
|
// and not after it. `markSaved()` used to read the stack depth when the
|
|
14719
15398
|
// RESPONSE landed, and a save round trip is hundreds of milliseconds —
|
|
@@ -14726,6 +15405,7 @@
|
|
|
14726
15405
|
// See UndoStack.saveToken()/markSaved() for why the receipt carries an op
|
|
14727
15406
|
// identity and not just a depth.
|
|
14728
15407
|
const token = stack.saveToken();
|
|
15408
|
+
paintSaveStatus('saving');
|
|
14729
15409
|
//
|
|
14730
15410
|
// ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
|
|
14731
15411
|
// that is a correction of something this file briefly carried. The premise
|
|
@@ -14754,8 +15434,9 @@
|
|
|
14754
15434
|
body: JSON.stringify({ fileId: ED.fileId, content: lines.join(EOL), baseMtimeMs: mtimeMs }),
|
|
14755
15435
|
});
|
|
14756
15436
|
} catch (e) {
|
|
14757
|
-
|
|
14758
|
-
|
|
15437
|
+
paintSaveStatus('failed');
|
|
15438
|
+
showError('無法儲存:連不到編輯伺服器(' + describeFailure(e) +
|
|
15439
|
+
')。變更還在這個分頁裡,請稍後再按 ' + SAVE_KEY_HINT + '。', null, null);
|
|
14759
15440
|
return;
|
|
14760
15441
|
}
|
|
14761
15442
|
if (res.status === 200) {
|
|
@@ -14763,7 +15444,8 @@
|
|
|
14763
15444
|
try {
|
|
14764
15445
|
j = await res.json();
|
|
14765
15446
|
} catch (e) {
|
|
14766
|
-
|
|
15447
|
+
paintSaveStatus('failed');
|
|
15448
|
+
showError('無法儲存:伺服器回傳的內容無法解析。變更還在這個分頁裡。', null, null);
|
|
14767
15449
|
return;
|
|
14768
15450
|
}
|
|
14769
15451
|
mtimeMs = j.mtimeMs;
|
|
@@ -14781,6 +15463,7 @@
|
|
|
14781
15463
|
return;
|
|
14782
15464
|
}
|
|
14783
15465
|
if (res.status === 409) {
|
|
15466
|
+
paintSaveStatus('failed');
|
|
14784
15467
|
showConflictBanner();
|
|
14785
15468
|
return;
|
|
14786
15469
|
}
|
|
@@ -14788,7 +15471,8 @@
|
|
|
14788
15471
|
// user's edits. Dirty state (and `mtimeMs`) is left untouched, and
|
|
14789
15472
|
// there is no auto-retry; the user decides what to do next.
|
|
14790
15473
|
const reason = await describeHttpFailure(res);
|
|
14791
|
-
|
|
15474
|
+
paintSaveStatus('failed');
|
|
15475
|
+
showError('無法儲存:' + reason + '。變更還在這個分頁裡。', null, null);
|
|
14792
15476
|
}
|
|
14793
15477
|
|
|
14794
15478
|
// v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
|
|
@@ -15067,12 +15751,6 @@
|
|
|
15067
15751
|
let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
|
|
15068
15752
|
let toolbarBlockEl = null;
|
|
15069
15753
|
let toolbarStateSig = '';
|
|
15070
|
-
// 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
|
|
15071
|
-
// state ('preview'); docsource.js's MODES comment records why.
|
|
15072
|
-
// docsource.js owns the cycle order and the lines[]<->string conversion.
|
|
15073
|
-
let docMode = 'edit';
|
|
15074
|
-
let sourceTextarea = null;
|
|
15075
|
-
let sourceBaseLines = null;
|
|
15076
15754
|
// 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
|
|
15077
15755
|
// event. A ClipboardEvent carries no modifier state of its own, so the
|
|
15078
15756
|
// intent has to be captured on the keystroke that produced it.
|
|
@@ -15109,9 +15787,10 @@
|
|
|
15109
15787
|
// stray-key hand-back raises this rather than only some of them.
|
|
15110
15788
|
const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
|
|
15111
15789
|
|
|
15112
|
-
// Extra
|
|
15113
|
-
// affordance (B
|
|
15114
|
-
//
|
|
15790
|
+
// Extra classes on the three inline buttons. Until v3.10.0 their text glyph
|
|
15791
|
+
// was the affordance (B bold, I italic, S struck) and these classes styled
|
|
15792
|
+
// it; the buttons draw Lucide icons now and the classes carry no CSS, but
|
|
15793
|
+
// they stay as stable hooks, same as .ed-seltb-b/-i/-s.
|
|
15115
15794
|
const TOOLBAR_BTN_CLASS = {
|
|
15116
15795
|
bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
|
|
15117
15796
|
};
|
|
@@ -15286,7 +15965,6 @@
|
|
|
15286
15965
|
// measures it here because toolbar-model.js may not touch the DOM, and
|
|
15287
15966
|
// .ed-seltb is painted from the same measurement — see updateToolbar().
|
|
15288
15967
|
marks: selectionMarkStates(sel),
|
|
15289
|
-
mode: docMode,
|
|
15290
15968
|
// v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
|
|
15291
15969
|
// above burstHasUncommittedEdit()'s own comment) is the SAME
|
|
15292
15970
|
// expression setDirty() paints the title dot from — not a second copy
|
|
@@ -15329,9 +16007,9 @@
|
|
|
15329
16007
|
btn.setAttribute('aria-pressed',
|
|
15330
16008
|
st.mixed ? 'mixed' : (st.active ? 'true' : 'false'));
|
|
15331
16009
|
}
|
|
15332
|
-
//
|
|
15333
|
-
// derived label
|
|
15334
|
-
|
|
16010
|
+
// v3.10.0: every button draws the fixed Lucide icon buildToolbar() gave
|
|
16011
|
+
// it. A derived label (H1..H6 on `headings`) no longer reaches the
|
|
16012
|
+
// button's content; the title below still follows the state.
|
|
15335
16013
|
// v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
|
|
15336
16014
|
// button's title names the NEXT state, so it has to be rewritten every
|
|
15337
16015
|
// time the mode changes. It rides the same diffed loop as everything
|
|
@@ -15457,10 +16135,11 @@
|
|
|
15457
16135
|
// Steps over disabled buttons. The walk is bounded by the button count and
|
|
15458
16136
|
// wraps, so a bar with nothing enabled would leave the index where it was
|
|
15459
16137
|
// rather than spin — see the model's own guarantee that this cannot happen
|
|
15460
|
-
// (deriveState() keeps `
|
|
15461
|
-
// the source-mode
|
|
15462
|
-
//
|
|
15463
|
-
//
|
|
16138
|
+
// (deriveState() keeps `outline` out of every disabling branch. Until
|
|
16139
|
+
// v3.10.0 the guarantee rested on the source-mode button `preview`; that
|
|
16140
|
+
// button is gone, and `outline` — enabled in all 264 ctx combinations a
|
|
16141
|
+
// sweep of blockType x inList x indent x dirty x hasSelection produces —
|
|
16142
|
+
// carries it now).
|
|
15464
16143
|
function moveToolbarCursor(delta) {
|
|
15465
16144
|
const btns = toolbarRovingBtns();
|
|
15466
16145
|
if (!btns.length) return;
|
|
@@ -15533,29 +16212,13 @@
|
|
|
15533
16212
|
// Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
|
|
15534
16213
|
// not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
|
|
15535
16214
|
// focusin comment is explicit that SOME toolbar activations (list,
|
|
15536
|
-
// ordered-list, check, table, insert-before, insert-after
|
|
16215
|
+
// ordered-list, check, table, insert-before, insert-after)
|
|
15537
16216
|
// deliberately land a focusin on an editable block WITHOUT ending keynav,
|
|
15538
16217
|
// so a real block surface must not trip this bail.
|
|
15539
16218
|
//
|
|
15540
|
-
// ⚠ FIX ROUND 1 (self-review, caught before ship): `preview`'s own landing
|
|
15541
|
-
// is `sourceTextarea` — a real `<textarea>`, not a `.ed-wys-armed` block —
|
|
15542
|
-
// so the exemption above named it but the first cut of this function did
|
|
15543
|
-
// not actually implement it, and the omission is load-bearing: source
|
|
15544
|
-
// mode's model disables every button except outline/preview (this file's
|
|
15545
|
-
// own "S/4.1 Source mode" comment — the cursor is meant to be walked with
|
|
15546
|
-
// Left/Right/Home/End between those two, THEN activated), and MEASURED,
|
|
15547
|
-
// that walk is exactly what this function's first cut broke — the very
|
|
15548
|
-
// next arrow key after landing in the source textarea silently exited
|
|
15549
|
-
// keynav instead of moving between the two enabled buttons, closing off
|
|
15550
|
-
// the one route backlog #6's own still-open `preview` round-trip gap (see
|
|
15551
|
-
// activateToolbarCursor()'s comment) names as "THE WAY OUT". `sourceTextarea`
|
|
15552
|
-
// is therefore excluded by reference (not by tag/class — an ordinary
|
|
15553
|
-
// `textarea.ed-raw`, e.g. backlog #6's own quote/code rescue, still trips
|
|
15554
|
-
// this bail exactly as intended, since typing there IS the user's own
|
|
15555
|
-
// text, not bar navigation).
|
|
15556
16219
|
function toolbarKeynavBlockedByRealControl() {
|
|
15557
16220
|
const ae = document.activeElement;
|
|
15558
|
-
if (!ae
|
|
16221
|
+
if (!ae) return false;
|
|
15559
16222
|
return !!(ae.tagName && (ae.tagName === 'INPUT' ||
|
|
15560
16223
|
ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
|
|
15561
16224
|
}
|
|
@@ -15566,8 +16229,9 @@
|
|
|
15566
16229
|
// WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
|
|
15567
16230
|
// enabled row in a plain paragraph, pressing Enter on the cursor and then
|
|
15568
16231
|
// typing to see whether the characters land anywhere — UPDATED for Task 9
|
|
15569
|
-
// (backlog #6), which fixed
|
|
15570
|
-
//
|
|
16232
|
+
// (backlog #6), which fixed both bullets below. (A third, the source-mode
|
|
16233
|
+
// button's return trip leaving the caret on BODY, was still open when
|
|
16234
|
+
// v3.10.0 removed source mode altogether.)
|
|
15571
16235
|
//
|
|
15572
16236
|
// * FIXED: quote, code and line used to end on BODY with typing going
|
|
15573
16237
|
// nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
|
|
@@ -15579,35 +16243,9 @@
|
|
|
15579
16243
|
// what dropped the caret). undoViaToolbar()/redoViaToolbar()/
|
|
15580
16244
|
// imageViaToolbar() now wrap the same captureCaretForSave()/
|
|
15581
16245
|
// restoreCaretForSave() pair saveViaToolbar() uses.
|
|
15582
|
-
// * STILL OPEN: `preview` (cycleDocMode(), the document-wide edit↔source
|
|
15583
|
-
// toggle) is UNCHANGED — going INTO source mode focuses the source
|
|
15584
|
-
// textarea correctly, but the RETURN trip (source → edit) still leaves
|
|
15585
|
-
// the caret on BODY, MEASURED unchanged by this task's own review.
|
|
15586
|
-
// Deliberately left open rather than rushed: unlike the two bullets
|
|
15587
|
-
// above, there is no established rescue to reuse here —
|
|
15588
|
-
// captureCaretForSave()'s block-id + text-offset coordinate system has
|
|
15589
|
-
// no meaning for a source-textarea cursor position (a raw character
|
|
15590
|
-
// offset into the WHOLE document string, not one block's rendered
|
|
15591
|
-
// text), so a correct fix needs its own coordinate mapping and its own
|
|
15592
|
-
// tests, not a copy-paste of this task's other two fixes. Backlog #6's
|
|
15593
|
-
// first two clauses no longer make this escape hatch NECESSARY the way
|
|
15594
|
-
// they used to (quote/code/line now have their own direct rescue,
|
|
15595
|
-
// rather than needing the preview round-trip as their only way out),
|
|
15596
|
-
// which is why this is deferred rather than treated as still blocking.
|
|
15597
16246
|
//
|
|
15598
16247
|
// A mouse click on those same buttons does exactly the same thing, so none
|
|
15599
|
-
// of this is something the keyboard route introduced
|
|
15600
|
-
// always climb out of the still-open `preview` case by clicking into any
|
|
15601
|
-
// OTHER editable text on the page, where a keyboard user cannot, because Tab
|
|
15602
|
-
// from BODY goes nowhere (this file's own §3.5 contract).
|
|
15603
|
-
//
|
|
15604
|
-
// THE WAY OUT for the still-open case, driven end to end from a stranded
|
|
15605
|
-
// BODY: the entry chord still works there (the bar's own state survives,
|
|
15606
|
-
// and its row is fully enabled), so walking to the mode button and pressing
|
|
15607
|
-
// Enter puts the caret in the source textarea, where typing lands. Coming
|
|
15608
|
-
// back the same way returns the document to edit mode but leaves the caret
|
|
15609
|
-
// on BODY again — the hatch is somewhere to keep typing, not (yet) a way
|
|
15610
|
-
// back to the WYSIWYG caret.
|
|
16248
|
+
// of this is something the keyboard route introduced.
|
|
15611
16249
|
function activateToolbarCursor() {
|
|
15612
16250
|
const btn = toolbarRovingBtns()[toolbarRovingIndex];
|
|
15613
16251
|
if (!btn || btn.disabled) return;
|
|
@@ -15640,7 +16278,7 @@
|
|
|
15640
16278
|
btn.type = 'button';
|
|
15641
16279
|
btn.className = 'ed-toolbar-btn' +
|
|
15642
16280
|
(TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
|
|
15643
|
-
btn.
|
|
16281
|
+
btn.innerHTML = icons.svg(def.icon, 18);
|
|
15644
16282
|
btn.title = def.title;
|
|
15645
16283
|
btn.setAttribute('aria-label', def.title);
|
|
15646
16284
|
btn.setAttribute('data-ed-tb', def.id);
|
|
@@ -15693,9 +16331,8 @@
|
|
|
15693
16331
|
}, true);
|
|
15694
16332
|
toolbarBtns = btns;
|
|
15695
16333
|
// v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
|
|
15696
|
-
//
|
|
15697
|
-
//
|
|
15698
|
-
// 切換時寫入當前模式名稱。
|
|
16334
|
+
// 但元素從未被建立。這裡把承諾兌現:佔好寬度。v3.10.0 起它顯示存檔狀態
|
|
16335
|
+
// (paintSaveStatus());原本寫的模式名稱隨原始碼模式一起移除。
|
|
15699
16336
|
const statusEl = document.createElement('span');
|
|
15700
16337
|
statusEl.className = 'ed-toolbar-status';
|
|
15701
16338
|
// Final wave, M7: the only announcement a mode switch ever gets. Every
|
|
@@ -15756,7 +16393,6 @@
|
|
|
15756
16393
|
// for the caret rescue.
|
|
15757
16394
|
case 'image': return imageViaToolbar();
|
|
15758
16395
|
case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
|
|
15759
|
-
case 'preview': return cycleDocMode();
|
|
15760
16396
|
default: return undefined;
|
|
15761
16397
|
}
|
|
15762
16398
|
}
|
|
@@ -15921,7 +16557,7 @@
|
|
|
15921
16557
|
if (!document.body.contains(blockEl)) {
|
|
15922
16558
|
liveBlockEl = reresolveBlockEl(identity) ||
|
|
15923
16559
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
15924
|
-
if (!liveBlockEl) {
|
|
16560
|
+
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
15925
16561
|
}
|
|
15926
16562
|
const rec = blockRecOf(liveBlockEl);
|
|
15927
16563
|
// A block that owns no source line has an inverted range, and there is
|
|
@@ -16076,14 +16712,14 @@
|
|
|
16076
16712
|
// two literals and then parses the bytes. Keep the two rules the same
|
|
16077
16713
|
// shape, and let the server's refusal be the one the user sees.
|
|
16078
16714
|
if (!insertableFile(blob)) {
|
|
16079
|
-
|
|
16715
|
+
showNotice(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE);
|
|
16080
16716
|
return null;
|
|
16081
16717
|
}
|
|
16082
16718
|
let data;
|
|
16083
16719
|
try {
|
|
16084
16720
|
data = await blobToBase64(blob);
|
|
16085
16721
|
} catch (e) {
|
|
16086
|
-
|
|
16722
|
+
showError('圖片讀取失敗 — ' + describeFailure(e), null, null);
|
|
16087
16723
|
return null;
|
|
16088
16724
|
}
|
|
16089
16725
|
let res;
|
|
@@ -16095,12 +16731,12 @@
|
|
|
16095
16731
|
}),
|
|
16096
16732
|
});
|
|
16097
16733
|
} catch (e) {
|
|
16098
|
-
|
|
16734
|
+
showError('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
|
|
16099
16735
|
return null;
|
|
16100
16736
|
}
|
|
16101
16737
|
if (!res.ok) {
|
|
16102
16738
|
const reason = await describeHttpFailure(res);
|
|
16103
|
-
|
|
16739
|
+
showError('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
|
|
16104
16740
|
return null;
|
|
16105
16741
|
}
|
|
16106
16742
|
let j;
|
|
@@ -16111,11 +16747,11 @@
|
|
|
16111
16747
|
// that is not a draw.io diagram -- so the reason has to arrive.
|
|
16112
16748
|
if (!res.ok) {
|
|
16113
16749
|
const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
|
|
16114
|
-
|
|
16750
|
+
showError('檔案上傳失敗 — ' + why, null, null);
|
|
16115
16751
|
return null;
|
|
16116
16752
|
}
|
|
16117
16753
|
if (!j || typeof j.path !== 'string') {
|
|
16118
|
-
|
|
16754
|
+
showError('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
|
|
16119
16755
|
return null;
|
|
16120
16756
|
}
|
|
16121
16757
|
return j.path;
|
|
@@ -16154,7 +16790,7 @@
|
|
|
16154
16790
|
}
|
|
16155
16791
|
if (!paths.length) return;
|
|
16156
16792
|
const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
|
|
16157
|
-
if (!target) {
|
|
16793
|
+
if (!target) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
|
|
16158
16794
|
const newLines = [];
|
|
16159
16795
|
paths.forEach((p, i) => {
|
|
16160
16796
|
if (i > 0) newLines.push('');
|
|
@@ -16219,7 +16855,7 @@
|
|
|
16219
16855
|
? blockEl
|
|
16220
16856
|
: (reresolveBlockEl(identity) ||
|
|
16221
16857
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
|
|
16222
|
-
if (!anchorEl) {
|
|
16858
|
+
if (!anchorEl) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
|
|
16223
16859
|
// Re-captured AFTER the commit: `identity` above holds the PRE-commit
|
|
16224
16860
|
// fingerprint, which the commit we just awaited may itself have
|
|
16225
16861
|
// invalidated. What has to survive the dialog is the anchor as it stands
|
|
@@ -16251,7 +16887,7 @@
|
|
|
16251
16887
|
// very symptom being fixed.
|
|
16252
16888
|
const live = document.body.contains(anchorEl)
|
|
16253
16889
|
? anchorEl : reresolveBlockEl(dialogIdentity);
|
|
16254
|
-
if (!live) {
|
|
16890
|
+
if (!live) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
|
|
16255
16891
|
insertImages(files, live);
|
|
16256
16892
|
});
|
|
16257
16893
|
input.click();
|
|
@@ -16279,31 +16915,11 @@
|
|
|
16279
16915
|
}
|
|
16280
16916
|
|
|
16281
16917
|
function wireImageDrop() {
|
|
16282
|
-
// ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
|
|
16283
|
-
// load-bearing rather than tidy-minded.
|
|
16284
|
-
//
|
|
16285
|
-
// In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
|
|
16286
|
-
// every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
|
|
16287
|
-
// ungated drop ran the whole upload plus an `insertBlockBelow()` commit
|
|
16288
|
-
// behind the hidden content, against `lines`. The textarea the user is
|
|
16289
|
-
// looking at still held the PRE-drop source, and `leaveSourceMode()` then
|
|
16290
|
-
// commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
|
|
16291
|
-
// `lines` — erasing the reference that was just inserted. MEASURED:
|
|
16292
|
-
// '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
|
|
16293
|
-
// leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
|
|
16294
|
-
// assets/x.png orphaned on disk. No error, no visible effect, and a stray
|
|
16295
|
-
// binary left in what is usually a git working tree.
|
|
16296
|
-
//
|
|
16297
|
-
// v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
|
|
16298
|
-
// the only other mode today, but the gate's reason is "the content the
|
|
16299
|
-
// user is looking at is not the content a drop would write into" — a
|
|
16300
|
-
// future third mode inherits the refusal rather than the hole.
|
|
16301
16918
|
document.addEventListener('dragover', (e) => {
|
|
16302
16919
|
// Nothing is dropped into a document behind a modal. Returning WITHOUT
|
|
16303
16920
|
// preventDefault() is what makes the browser refuse the drop, so the
|
|
16304
16921
|
// gesture is declined visibly rather than swallowed.
|
|
16305
16922
|
if (waveEditorIsModal()) return;
|
|
16306
|
-
if (docMode !== 'edit') return;
|
|
16307
16923
|
if (!dragCarriesFiles(e)) return;
|
|
16308
16924
|
e.preventDefault();
|
|
16309
16925
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
@@ -16313,7 +16929,6 @@
|
|
|
16313
16929
|
// took the document behind it from 4 blocks to 5 and put an `<img>` into
|
|
16314
16930
|
// `.content`, with the overlay still up and saying nothing.
|
|
16315
16931
|
if (waveEditorIsModal()) return;
|
|
16316
|
-
if (docMode !== 'edit') return;
|
|
16317
16932
|
if (!dragCarriesFiles(e)) return;
|
|
16318
16933
|
const files = e.dataTransfer
|
|
16319
16934
|
? Array.prototype.slice.call(e.dataTransfer.files || [])
|
|
@@ -16433,94 +17048,26 @@
|
|
|
16433
17048
|
}, true);
|
|
16434
17049
|
}
|
|
16435
17050
|
|
|
16436
|
-
//
|
|
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() {
|
|
17051
|
+
// The toolbar's right-hand status slot says whether the file on disk matches
|
|
17052
|
+
// the page. Save stays manual (Ctrl/Cmd+S or the save button); this only
|
|
17053
|
+
// makes the state visible. role="status" (mountToolbar()) so a screen
|
|
17054
|
+
// reader hears it. v3.10.0: it used to name the document mode, which went
|
|
17055
|
+
// with source mode. The save button's aria-pressed follows 'dirty' — save
|
|
17056
|
+
// is not in TOOLBAR_TOGGLE_IDS, so updateToolbar() never writes it back.
|
|
17057
|
+
const SAVE_KEY_HINT = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘S' : 'Ctrl+S';
|
|
17058
|
+
function paintSaveStatus(state) {
|
|
16522
17059
|
const statusEl = document.querySelector('.ed-toolbar-status');
|
|
16523
|
-
if (statusEl)
|
|
17060
|
+
if (!statusEl) return;
|
|
17061
|
+
// setDirty() runs on every burst mutation; rewriting an unchanged role=
|
|
17062
|
+
// "status" region would have a screen reader re-announce it per keystroke.
|
|
17063
|
+
if (statusEl.getAttribute('data-state') === state) return;
|
|
17064
|
+
statusEl.setAttribute('data-state', state);
|
|
17065
|
+
if (state === 'clean') statusEl.innerHTML = icons.svg('check', 15) + '<span>已儲存</span>';
|
|
17066
|
+
else if (state === 'dirty') statusEl.innerHTML = '<i class="ed-status-dot"></i><span>有未儲存的變更 · ' + SAVE_KEY_HINT + ' 儲存</span>';
|
|
17067
|
+
else if (state === 'saving') statusEl.innerHTML = '<span>儲存中…</span>';
|
|
17068
|
+
else statusEl.innerHTML = icons.svg('circle-alert', 15) + '<span>無法儲存</span>';
|
|
17069
|
+
const btn = toolbarBtns && toolbarBtns.save;
|
|
17070
|
+
if (btn) btn.setAttribute('aria-pressed', state === 'dirty' ? 'true' : 'false');
|
|
16524
17071
|
}
|
|
16525
17072
|
|
|
16526
17073
|
// ── mount ──────────────────────────────────────────────────────────────
|
|
@@ -16600,8 +17147,7 @@
|
|
|
16600
17147
|
// while both had to end up with the hint out.
|
|
16601
17148
|
toolbarEl.addEventListener('scroll', paintToolbarOverflow, { passive: true });
|
|
16602
17149
|
window.addEventListener('resize', paintToolbarOverflow);
|
|
16603
|
-
|
|
16604
|
-
paintModeStatus();
|
|
17150
|
+
paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
|
|
16605
17151
|
wireToolbarTracking();
|
|
16606
17152
|
wireImageDrop();
|
|
16607
17153
|
wirePlainPasteArming();
|
|
@@ -16620,10 +17166,7 @@
|
|
|
16620
17166
|
// That bail returns for EVERY key, so an entry gesture placed under it
|
|
16621
17167
|
// would be dead while a degraded block's raw editor holds focus — driven
|
|
16622
17168
|
// from that editor, the gesture lands on the bar and the caret stays in
|
|
16623
|
-
// the textarea.
|
|
16624
|
-
// which that bail does not match) and reaches the bar as well; there the
|
|
16625
|
-
// model disables the row apart from the button that gets you back out, so
|
|
16626
|
-
// the cursor lands on that one.
|
|
17169
|
+
// the textarea.
|
|
16627
17170
|
//
|
|
16628
17171
|
// Escape is NOT claimed here. It belongs to the ordered prologue just
|
|
16629
17172
|
// below, which already ranks a drag and an open menu above everything
|
|
@@ -16789,7 +17332,7 @@
|
|
|
16789
17332
|
e.key !== 'Control' && e.key !== 'Meta') {
|
|
16790
17333
|
exitToolbarKeynav();
|
|
16791
17334
|
if (e.key !== 'Tab') {
|
|
16792
|
-
|
|
17335
|
+
showNotice(TOOLBAR_KEYNAV_EXIT_MESSAGE);
|
|
16793
17336
|
activeBannerIsKeynavExit = true;
|
|
16794
17337
|
}
|
|
16795
17338
|
}
|
|
@@ -17746,7 +18289,7 @@
|
|
|
17746
18289
|
return;
|
|
17747
18290
|
}
|
|
17748
18291
|
drawioDeferredNotice = null;
|
|
17749
|
-
|
|
18292
|
+
showNotice(message);
|
|
17750
18293
|
}
|
|
17751
18294
|
function flushDrawioNotice() {
|
|
17752
18295
|
if (!drawioDeferredNotice) return;
|
|
@@ -17757,7 +18300,7 @@
|
|
|
17757
18300
|
if (activeBanner || noticeTeardownWouldDisrupt()) return;
|
|
17758
18301
|
const m = drawioDeferredNotice.message;
|
|
17759
18302
|
drawioDeferredNotice = null;
|
|
17760
|
-
|
|
18303
|
+
showNotice(m);
|
|
17761
18304
|
}
|
|
17762
18305
|
const DRAWIO_PAGE_GONE_MSG =
|
|
17763
18306
|
'目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
|
|
@@ -18071,6 +18614,45 @@
|
|
|
18071
18614
|
}
|
|
18072
18615
|
}
|
|
18073
18616
|
|
|
18617
|
+
// v3.10.0: an edit made outside the browser. Clean page: reload it and land
|
|
18618
|
+
// on the same heading. Unsaved work here: say so now, once per outside
|
|
18619
|
+
// version, instead of waiting for the save to bounce with a 409.
|
|
18620
|
+
// documentIsDirty() already counts an uncommitted burst, so typing that has
|
|
18621
|
+
// not been committed yet takes the conflict branch (Review Focus 4).
|
|
18622
|
+
let conflictShownFor = 0;
|
|
18623
|
+
function onDiskMtime(disk) {
|
|
18624
|
+
if (disk === mtimeMs || waveEditorIsModal()) return;
|
|
18625
|
+
// An open MD 原始碼 box with typed-but-uncommitted text is unsaved work
|
|
18626
|
+
// too, and documentIsDirty() cannot see it (a textarea's value touches no
|
|
18627
|
+
// block's innerHTML). A reload would silently throw it away.
|
|
18628
|
+
const rawPending = !!(activeEditor && activeEditor.hasChanges());
|
|
18629
|
+
if (!documentIsDirty() && !rawPending) {
|
|
18630
|
+
try { sessionStorage.setItem('md2doc-reload-anchor', JSON.stringify(readingAnchor())); } catch (e) { /* private mode */ }
|
|
18631
|
+
location.reload();
|
|
18632
|
+
return;
|
|
18633
|
+
}
|
|
18634
|
+
if (conflictShownFor === disk) return;
|
|
18635
|
+
conflictShownFor = disk;
|
|
18636
|
+
showConflictBanner();
|
|
18637
|
+
}
|
|
18638
|
+
function readingAnchor() {
|
|
18639
|
+
const hs = [].slice.call(document.querySelectorAll('.content h1[id], .content h2[id], .content h3[id], .content h4[id]'));
|
|
18640
|
+
let best = null;
|
|
18641
|
+
for (const h of hs) { if (h.getBoundingClientRect().top <= 80) best = h; else break; }
|
|
18642
|
+
return best ? { id: best.id, off: Math.round(best.getBoundingClientRect().top) } : { y: Math.round(scrollY) };
|
|
18643
|
+
}
|
|
18644
|
+
(function restoreReloadAnchor() {
|
|
18645
|
+
let a = null;
|
|
18646
|
+
try { a = JSON.parse(sessionStorage.getItem('md2doc-reload-anchor') || 'null'); sessionStorage.removeItem('md2doc-reload-anchor'); } catch (e) { a = null; }
|
|
18647
|
+
if (!a) return;
|
|
18648
|
+
requestAnimationFrame(() => {
|
|
18649
|
+
const h = a.id && document.getElementById(a.id);
|
|
18650
|
+
if (h) scrollTo(0, scrollY + h.getBoundingClientRect().top - a.off);
|
|
18651
|
+
else if (typeof a.y === 'number') scrollTo(0, a.y);
|
|
18652
|
+
showNotice('檔案在別處被修改,已重新載入。');
|
|
18653
|
+
});
|
|
18654
|
+
})();
|
|
18655
|
+
|
|
18074
18656
|
setInterval(() => {
|
|
18075
18657
|
// A notice this refresh could not show earlier because the slot was
|
|
18076
18658
|
// taken (see noteDrawioNotice) gets its next chance here, on the same
|
|
@@ -18095,10 +18677,17 @@
|
|
|
18095
18677
|
// is the accepted worst-case latency for this whole feature.
|
|
18096
18678
|
const ack = drawioAckPending;
|
|
18097
18679
|
drawioAckPending = null;
|
|
18680
|
+
const pingSaveGen = saveGen;
|
|
18681
|
+
const pingSavesInFlight = savesInFlight;
|
|
18098
18682
|
fetch('/api/ping', {
|
|
18099
18683
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
18100
18684
|
body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
|
|
18101
18685
|
}).then((res) => {
|
|
18686
|
+
// v3.10.0: X-Md2doc-Mtime is the file's mtime as the server saw it. Only
|
|
18687
|
+
// comparable when no save started or ran during this round trip.
|
|
18688
|
+
const disk = Number(res.headers.get('x-md2doc-mtime'));
|
|
18689
|
+
if (isFinite(disk) && disk > 0 && saveGen === pingSaveGen &&
|
|
18690
|
+
pingSavesInFlight === 0 && savesInFlight === 0) onDiskMtime(disk);
|
|
18102
18691
|
if (res.status !== 200) return null;
|
|
18103
18692
|
return res.json().catch(() => null);
|
|
18104
18693
|
}).then((j) => {
|
|
@@ -18748,7 +19337,7 @@
|
|
|
18748
19337
|
// missing from the file after the user has read about it, and the dirty
|
|
18749
19338
|
// net is about the work, not about the notice.
|
|
18750
19339
|
let el = null;
|
|
18751
|
-
el =
|
|
19340
|
+
el = showError(
|
|
18752
19341
|
'這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
|
|
18753
19342
|
'比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
|
|
18754
19343
|
'(不含前後兩道圍欄)。原因:' + reason,
|
|
@@ -18866,7 +19455,7 @@
|
|
|
18866
19455
|
// Kept, with a hole in it. The user is told once more on the way out,
|
|
18867
19456
|
// because the status line they were reading has just been removed from
|
|
18868
19457
|
// the screen along with the rest of the dialog.
|
|
18869
|
-
|
|
19458
|
+
showError(
|
|
18870
19459
|
'這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
|
|
18871
19460
|
'寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
|
|
18872
19461
|
seam.endLine + ' 行)。',
|
|
@@ -18904,7 +19493,7 @@
|
|
|
18904
19493
|
// it. Losing a session is bad; silently corrupting the document the user
|
|
18905
19494
|
// keeps editing afterwards is worse, and before fix 1 this path risked
|
|
18906
19495
|
// both at once.
|
|
18907
|
-
|
|
19496
|
+
showError(
|
|
18908
19497
|
'畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
|
|
18909
19498
|
'Ctrl+Z 可以撤銷)。', null, null);
|
|
18910
19499
|
}
|