@helping-ai-workflow/md2doc 3.16.0 → 3.17.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 +2459 -106
- package/lib/editor/code-edit.js +184 -0
- package/lib/editor/icons.js +3 -0
- package/lib/editor/inline-md.js +105 -1
- package/lib/editor/server.js +30 -2
- package/lib/highlight.js +46 -5
- package/lib/md2doc.js +257 -39
- package/lib/theme/runtime.js +33 -2
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -988,6 +988,13 @@
|
|
|
988
988
|
// never reproduce the lockout — any editor that was open is gone by
|
|
989
989
|
// construction the moment the swap happens.
|
|
990
990
|
let activeEditor = null; // { blockEl, hasChanges(), commitNow(), cancelNow() } | null
|
|
991
|
+
// v3.17.0 T2: the open in-place code edit (editCodeInPlace()), or null.
|
|
992
|
+
// While it is open its `editor` IS `activeEditor`; liveCodeEdit() is the
|
|
993
|
+
// reader that also notices a render having ended it.
|
|
994
|
+
let codeEditState = null;
|
|
995
|
+
// v3.17.0 T5: the open diagram panel (openDiagramPanel()), or null; its
|
|
996
|
+
// `editor` is `activeEditor` while it is open, liveDiagramPanel() reads it.
|
|
997
|
+
let diagramPanelState = null;
|
|
991
998
|
|
|
992
999
|
// Task 2 (Phase 3): the currently-open always-on WYSIWYG "burst" — at most
|
|
993
1000
|
// one paragraph/heading contenteditable surface is being edited at a time,
|
|
@@ -1379,8 +1386,31 @@
|
|
|
1379
1386
|
// in `lines` (pendingStructure) — unsaved work like an open burst's text.
|
|
1380
1387
|
// Measured before: a failed split, then the file changed on disk, and
|
|
1381
1388
|
// onDiskMtime() reloaded the page silently, the split gone.
|
|
1382
|
-
|
|
1389
|
+
// v3.17.0 T2 fix round 1 (R47): code typed into an open in-place edit is
|
|
1390
|
+
// unsaved work too — it reaches `lines` only when the edit commits. T5
|
|
1391
|
+
// (R56 (d)): so is the source typed into an open diagram panel.
|
|
1392
|
+
return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null ||
|
|
1393
|
+
codeEditHasUncommittedText() || diagramPanelHasUncommittedText();
|
|
1394
|
+
}
|
|
1395
|
+
// Guarded the way burstHasUncommittedEdit() is: this also runs inside the
|
|
1396
|
+
// beforeunload handler, where a throw would drop the navigation guard.
|
|
1397
|
+
function codeEditHasUncommittedText() {
|
|
1398
|
+
const s = codeEditState;
|
|
1399
|
+
if (!s || activeEditor !== s.editor) return false;
|
|
1400
|
+
try { return s.host.isConnected && s.editor.hasChanges(); } catch (e) { return false; }
|
|
1401
|
+
}
|
|
1402
|
+
// A pure read (v3.17.0 T10): setDirty() asks this on every call, and it used
|
|
1403
|
+
// to dispose a panel its render had taken away as a side effect (fix round 1,
|
|
1404
|
+
// M3). The dispose now lives in reapDiagramPanel(), which both render tails
|
|
1405
|
+
// call just before their setDirty(); the timer and the theme callback reap
|
|
1406
|
+
// lazily through liveDiagramPanel() as they always did.
|
|
1407
|
+
function diagramPanelHasUncommittedText() {
|
|
1408
|
+
try {
|
|
1409
|
+
const s = diagramPanelState;
|
|
1410
|
+
return !!s && activeEditor === s.editor && s.panel.isConnected && s.editor.hasChanges();
|
|
1411
|
+
} catch (e) { return false; }
|
|
1383
1412
|
}
|
|
1413
|
+
function reapDiagramPanel() { liveDiagramPanel(); }
|
|
1384
1414
|
|
|
1385
1415
|
function setDirty() {
|
|
1386
1416
|
document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
|
|
@@ -2618,6 +2648,9 @@
|
|
|
2618
2648
|
hideTableInsertBubbles();
|
|
2619
2649
|
hideTableAddBars();
|
|
2620
2650
|
hideTableGrips();
|
|
2651
|
+
reconcileCodeChips(); // v3.17.0 T3
|
|
2652
|
+
reconcileDiagramEditBtn(); // v3.17.0 T4
|
|
2653
|
+
reconcileImageToolbar(); // v3.17.0 T7
|
|
2621
2654
|
// Task 4 fix (review finding): the SAME reasoning applies to the
|
|
2622
2655
|
// selection toolbar and its document-level selectionchange listener.
|
|
2623
2656
|
// Every call site today resolves the session via switchAwayFrom() first
|
|
@@ -2816,6 +2849,7 @@
|
|
|
2816
2849
|
// every safeRerenderAll() caller the commit landed. Omitting either is the
|
|
2817
2850
|
// exact silent-failure shape ruling F4 warns about — safeRerenderAll()'s
|
|
2818
2851
|
// try/catch would swallow a throw and the DOM would still look right.
|
|
2852
|
+
reapDiagramPanel(); // T10: a panel this render took away is disposed here
|
|
2819
2853
|
setDirty();
|
|
2820
2854
|
return true;
|
|
2821
2855
|
}
|
|
@@ -2877,6 +2911,9 @@
|
|
|
2877
2911
|
hideTableInsertBubbles();
|
|
2878
2912
|
hideTableAddBars();
|
|
2879
2913
|
hideTableGrips();
|
|
2914
|
+
reconcileCodeChips(); // v3.17.0 T3
|
|
2915
|
+
reconcileDiagramEditBtn(); // v3.17.0 T4
|
|
2916
|
+
reconcileImageToolbar(); // v3.17.0 T7
|
|
2880
2917
|
// Task 4 fix (review finding): the SAME reasoning applies to the
|
|
2881
2918
|
// selection toolbar and its document-level selectionchange listener —
|
|
2882
2919
|
// whatever WYSIWYG session was open a moment ago just got detached by
|
|
@@ -2984,6 +3021,7 @@
|
|
|
2984
3021
|
// navigation-rebuild failure break block selection / save / undo.
|
|
2985
3022
|
}
|
|
2986
3023
|
}
|
|
3024
|
+
reapDiagramPanel(); // T10: a panel this render took away is disposed here
|
|
2987
3025
|
setDirty();
|
|
2988
3026
|
return true;
|
|
2989
3027
|
}
|
|
@@ -4415,11 +4453,18 @@
|
|
|
4415
4453
|
return true;
|
|
4416
4454
|
}
|
|
4417
4455
|
|
|
4456
|
+
// v3.17.0 T9a (M-f): a leave by key hands the caret on once it lands
|
|
4457
|
+
// (handCaretOnBelow()); `endLine` is read at the key, as the span it ends.
|
|
4458
|
+
function leaveByKey(leave) {
|
|
4459
|
+
const tail = lines.length - endLine;
|
|
4460
|
+
return leave().then((ok) => { if (ok) handCaretOnBelow(tail); }).catch(showUnexpectedFailure);
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4418
4463
|
ta.addEventListener('input', () => autoSize(ta));
|
|
4419
4464
|
ta.addEventListener('keydown', (e) => {
|
|
4420
4465
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
4421
4466
|
e.preventDefault();
|
|
4422
|
-
commit
|
|
4467
|
+
leaveByKey(commit);
|
|
4423
4468
|
} else if (e.key === 'Enter' && seed.enterToBody && !e.shiftKey && !e.altKey && !e.isComposing &&
|
|
4424
4469
|
ta.selectionStart === ta.selectionEnd && ta.value.indexOf('\n') !== -1 &&
|
|
4425
4470
|
ta.selectionStart <= ta.value.indexOf('\n')) {
|
|
@@ -4432,13 +4477,1082 @@
|
|
|
4432
4477
|
ta.setSelectionRange(body, body);
|
|
4433
4478
|
} else if (e.key === 'Escape') {
|
|
4434
4479
|
e.preventDefault();
|
|
4435
|
-
cancelAndMaybeDiscard
|
|
4480
|
+
leaveByKey(cancelAndMaybeDiscard);
|
|
4436
4481
|
}
|
|
4437
4482
|
});
|
|
4438
4483
|
commitBtn.addEventListener('click', commit);
|
|
4439
4484
|
cancelBtn.addEventListener('click', cancelAndMaybeDiscard);
|
|
4440
4485
|
}
|
|
4441
4486
|
|
|
4487
|
+
// ── v3.17.0 T2: a fenced code block edits in place (D1–D5, D10) ────────
|
|
4488
|
+
//
|
|
4489
|
+
// A click on a fenced code block, the "/" / + / toolbar 程式碼 insert, the
|
|
4490
|
+
// ``` shortcut and 轉換成 程式碼 open this instead of the raw box (R21,
|
|
4491
|
+
// R28). The block's own <pre> becomes the editing host (contenteditable and
|
|
4492
|
+
// data-ed-code-edit on the pre, R26) and shows the body only: no fence on
|
|
4493
|
+
// screen. ⠿ MD 原始碼 and Enter on a selected block still open the whole
|
|
4494
|
+
// block, fences included, in the raw box.
|
|
4495
|
+
//
|
|
4496
|
+
// It is an `activeEditor` like the raw box, so switchAwayFrom(), Ctrl+S, a
|
|
4497
|
+
// click elsewhere and the flight guard resolve it the same way. Its commit
|
|
4498
|
+
// keeps the eight obligations of openRawEditor()'s commit() and
|
|
4499
|
+
// cancelAndMaybeDiscard() above (R27), written out again here because those
|
|
4500
|
+
// close over the textarea: the R84 reconcile guard first; an unchanged text
|
|
4501
|
+
// and language is the cancel path (no op, no undo step); a failed render
|
|
4502
|
+
// rolls back and leaves the edit open on its text; a superseded render is
|
|
4503
|
+
// not success yet; `activeEditor` is cleared before any write that detaches
|
|
4504
|
+
// the focused host; a node the patch kept loses its editing attributes;
|
|
4505
|
+
// reanchor() false ends the visual edit too; and an untouched pristine
|
|
4506
|
+
// insert is discarded on the cancel path.
|
|
4507
|
+
//
|
|
4508
|
+
// Keys (D3): Enter = a newline plus the line's indent, Tab / Shift+Tab
|
|
4509
|
+
// indent or dedent by two spaces, Esc and Ctrl/Cmd+Enter commit and leave,
|
|
4510
|
+
// Ctrl/Cmd+Z and Ctrl/Cmd+Y (Ctrl+Shift+Z) step the edit's own TextHistory
|
|
4511
|
+
// (D4): native undo is useless here, a re-colour replaces the host's
|
|
4512
|
+
// children. Undo with nothing left does nothing; it never reaches the
|
|
4513
|
+
// document stack while the edit is open. Ctrl+S commits, saves and opens
|
|
4514
|
+
// the edit again at the same caret (R48, commitCodeEditAndReenter()).
|
|
4515
|
+
const codeEditLib = window.md2docCodeEdit;
|
|
4516
|
+
const CODE_EDIT_TICK_MS = 300;
|
|
4517
|
+
|
|
4518
|
+
// D1: a block of type code whose rendered first child is a <pre> (Mermaid,
|
|
4519
|
+
// Graphviz, math and WaveDrom render something else) and whose first
|
|
4520
|
+
// source line opens a fence (indented code has none).
|
|
4521
|
+
function isFencedCodeBlock(blockEl) {
|
|
4522
|
+
if (!codeEditLib || !blockEl || !blockEl.getAttribute) return false;
|
|
4523
|
+
if (blockEl.getAttribute('data-block-type') !== 'code') return false;
|
|
4524
|
+
const first = blockEl.firstElementChild;
|
|
4525
|
+
if (!first || first.nodeName !== 'PRE') return false;
|
|
4526
|
+
const rec = blockRecOf(blockEl);
|
|
4527
|
+
if (!rec || rec.endLine < rec.startLine) return false;
|
|
4528
|
+
return codeEditLib.parseFence(lines.slice(rec.startLine - 1, rec.endLine)) !== null;
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4531
|
+
// The open edit, or null. A render that replaced the host, or cleared
|
|
4532
|
+
// `activeEditor` for it, has ended it; its timer and listeners go with it.
|
|
4533
|
+
function liveCodeEdit() {
|
|
4534
|
+
const s = codeEditState;
|
|
4535
|
+
if (!s) return null;
|
|
4536
|
+
if (activeEditor === s.editor && s.host.isConnected) return s;
|
|
4537
|
+
codeEditState = null;
|
|
4538
|
+
disposeCodeEdit(s);
|
|
4539
|
+
return null;
|
|
4540
|
+
}
|
|
4541
|
+
function disposeCodeEdit(s) {
|
|
4542
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
4543
|
+
s.listeners.abort();
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
function codeEditText(s) { return s.host.textContent; }
|
|
4547
|
+
// [start, end] of the selection as character offsets in the host, or null
|
|
4548
|
+
// when the selection is not inside it.
|
|
4549
|
+
function codeEditSel(s) {
|
|
4550
|
+
const sel = window.getSelection();
|
|
4551
|
+
if (!sel || !sel.rangeCount) return null;
|
|
4552
|
+
const r = sel.getRangeAt(0);
|
|
4553
|
+
if (!s.host.contains(r.startContainer) || !s.host.contains(r.endContainer)) return null;
|
|
4554
|
+
return [offsetInSurface(s.host, r.startContainer, r.startOffset),
|
|
4555
|
+
offsetInSurface(s.host, r.endContainer, r.endOffset)];
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
function codeEditEscape(str) {
|
|
4559
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
4560
|
+
}
|
|
4561
|
+
// The host's children for `text`: one <code>, rebuilt every time (so a
|
|
4562
|
+
// Select All + Delete that took it out gets it back), coloured when the
|
|
4563
|
+
// browser highlighter is loaded and knows the language (D5), plain
|
|
4564
|
+
// otherwise. A trailing newline gets a <br> after it, inside the <code>:
|
|
4565
|
+
// a final newline draws no empty line, and both engines then put what is
|
|
4566
|
+
// typed after it BEFORE it (measured; a <br> outside the <code>, which is
|
|
4567
|
+
// display: block while editing, does not help). The <br> is not text —
|
|
4568
|
+
// textContent and the offset helpers skip it.
|
|
4569
|
+
function codeEditHostHtml(text, info) {
|
|
4570
|
+
const hl = window.md2docHljs;
|
|
4571
|
+
let coloured = null;
|
|
4572
|
+
if (hl && typeof hl.highlightCode === 'function') {
|
|
4573
|
+
try { coloured = hl.highlightCode(text, info); } catch (e) { coloured = null; }
|
|
4574
|
+
}
|
|
4575
|
+
return '<code class="language-' + codeEditEscape(info) + (coloured !== null ? ' hljs' : '') + '">' +
|
|
4576
|
+
(coloured !== null ? coloured : codeEditEscape(text)) +
|
|
4577
|
+
(text.charAt(text.length - 1) === '\n' ? '<br data-ed-code-tail="">' : '') + '</code>';
|
|
4578
|
+
}
|
|
4579
|
+
// The one write of the host's children. The host is the focused element
|
|
4580
|
+
// itself and stays in the document, so the write detaches no focused node
|
|
4581
|
+
// (detach census (d); measured: no focus event across a re-colour).
|
|
4582
|
+
function writeCodeEditHost(s, text) {
|
|
4583
|
+
s.host.innerHTML = codeEditHostHtml(text, s.lang);
|
|
4584
|
+
s.shown = text;
|
|
4585
|
+
}
|
|
4586
|
+
function placeCodeEditSelection(s, a, f) {
|
|
4587
|
+
const walker = document.createTreeWalker(s.host, NodeFilter.SHOW_TEXT);
|
|
4588
|
+
if (!walker.nextNode()) {
|
|
4589
|
+
// An empty body: the caret goes inside the <code>, so what is typed
|
|
4590
|
+
// lands in it.
|
|
4591
|
+
const code = s.host.querySelector('code') || s.host;
|
|
4592
|
+
window.getSelection().setBaseAndExtent(code, 0, code, 0);
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
restoreSelection(s.host, a, f === undefined ? a : f);
|
|
4596
|
+
}
|
|
4597
|
+
function setCodeEditText(s, text, a, f) {
|
|
4598
|
+
writeCodeEditHost(s, text);
|
|
4599
|
+
if (document.activeElement === s.host) placeCodeEditSelection(s, a, f);
|
|
4600
|
+
setDirty(); // R47: the dot and the status follow the edit's own text
|
|
4601
|
+
}
|
|
4602
|
+
// D5: re-colour what is there now, the selection put back by offset.
|
|
4603
|
+
function recolourCodeEdit(s) {
|
|
4604
|
+
const text = codeEditText(s);
|
|
4605
|
+
const sel = codeEditSel(s);
|
|
4606
|
+
writeCodeEditHost(s, text);
|
|
4607
|
+
if (sel && document.activeElement === s.host) placeCodeEditSelection(s, sel[0], sel[1]);
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4610
|
+
// D4: a history step at most once per idle tick of typing, and the
|
|
4611
|
+
// re-colour with it — never inside an IME composition (compositionend
|
|
4612
|
+
// starts the tick again).
|
|
4613
|
+
function scheduleCodeEditTick(s) {
|
|
4614
|
+
if (s.timer) clearTimeout(s.timer);
|
|
4615
|
+
s.timer = setTimeout(() => {
|
|
4616
|
+
s.timer = null;
|
|
4617
|
+
if (liveCodeEdit() !== s || s.composing) return;
|
|
4618
|
+
pushCodeEditHistory(s);
|
|
4619
|
+
recolourCodeEdit(s);
|
|
4620
|
+
}, CODE_EDIT_TICK_MS);
|
|
4621
|
+
}
|
|
4622
|
+
function pushCodeEditHistory(s) {
|
|
4623
|
+
const text = codeEditText(s);
|
|
4624
|
+
const sel = codeEditSel(s) || [text.length, text.length];
|
|
4625
|
+
s.history.push({ text, selStart: sel[0], selEnd: sel[1] });
|
|
4626
|
+
}
|
|
4627
|
+
// The typing since the last tick becomes its own step before an action
|
|
4628
|
+
// that makes one of its own (Enter, Tab, paste, undo, a commit).
|
|
4629
|
+
function flushCodeEditTyping(s) {
|
|
4630
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
4631
|
+
if (!s.composing) pushCodeEditHistory(s);
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
// The text and selection one edit gives: 'enter' (D3: newline + indent),
|
|
4635
|
+
// 'tab' / 'dedent', 'insert' (`data`), 'backspace'.
|
|
4636
|
+
function codeEditEditResult(s, kind, data) {
|
|
4637
|
+
const text = codeEditText(s);
|
|
4638
|
+
const sel = codeEditSel(s) || [text.length, text.length];
|
|
4639
|
+
if (kind === 'enter') {
|
|
4640
|
+
const n = codeEditLib.newlineWithIndent(text.slice(0, sel[0]) + text.slice(sel[1]), sel[0]);
|
|
4641
|
+
return { text: n.text, selStart: n.caret, selEnd: n.caret };
|
|
4642
|
+
}
|
|
4643
|
+
if (kind === 'insert') {
|
|
4644
|
+
const at = sel[0] + data.length;
|
|
4645
|
+
return { text: text.slice(0, sel[0]) + data + text.slice(sel[1]), selStart: at, selEnd: at };
|
|
4646
|
+
}
|
|
4647
|
+
if (kind === 'backspace') {
|
|
4648
|
+
const from = sel[0] === sel[1] ? Math.max(0, sel[0] - 1) : sel[0];
|
|
4649
|
+
return { text: text.slice(0, from) + text.slice(sel[1]), selStart: from, selEnd: from };
|
|
4650
|
+
}
|
|
4651
|
+
if (kind === 'tab' && sel[0] === sel[1]) {
|
|
4652
|
+
return { text: text.slice(0, sel[0]) + ' ' + text.slice(sel[0]), selStart: sel[0] + 2, selEnd: sel[0] + 2 };
|
|
4653
|
+
}
|
|
4654
|
+
return codeEditLib.indentLines(text, sel[0], sel[1], kind === 'dedent');
|
|
4655
|
+
}
|
|
4656
|
+
function codeEditKeyEdit(s, kind, data) {
|
|
4657
|
+
flushCodeEditTyping(s);
|
|
4658
|
+
const r = codeEditEditResult(s, kind, data);
|
|
4659
|
+
setCodeEditText(s, r.text, r.selStart, r.selEnd);
|
|
4660
|
+
s.history.push({ text: r.text, selStart: r.selStart, selEnd: r.selEnd });
|
|
4661
|
+
}
|
|
4662
|
+
function stepCodeEditHistory(s, dir) {
|
|
4663
|
+
flushCodeEditTyping(s);
|
|
4664
|
+
const snap = dir < 0 ? s.history.undo() : s.history.redo();
|
|
4665
|
+
if (!snap) return; // nothing left: the document stack is never reached from here
|
|
4666
|
+
setCodeEditText(s, snap.text, snap.selStart, snap.selEnd);
|
|
4667
|
+
}
|
|
4668
|
+
|
|
4669
|
+
function onCodeEditKeydown(s, e) {
|
|
4670
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
4671
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
4672
|
+
const key = e.key;
|
|
4673
|
+
let act = null;
|
|
4674
|
+
if (key === 'Escape' || (key === 'Enter' && mod)) act = 'leave';
|
|
4675
|
+
else if (mod && !e.altKey && !e.shiftKey && (key === 's' || key === 'S')) act = 'save';
|
|
4676
|
+
else if (key === 'Enter' && !e.altKey) act = 'enter';
|
|
4677
|
+
else if (key === 'Tab' && !mod && !e.altKey) act = e.shiftKey ? 'dedent' : 'tab';
|
|
4678
|
+
else if (mod && !e.altKey && !e.shiftKey && (key === 'z' || key === 'Z')) act = 'undo';
|
|
4679
|
+
else if (mod && !e.altKey && ((!e.shiftKey && (key === 'y' || key === 'Y')) ||
|
|
4680
|
+
(e.shiftKey && (key === 'z' || key === 'Z')))) act = 'redo';
|
|
4681
|
+
if (!act) return;
|
|
4682
|
+
e.preventDefault();
|
|
4683
|
+
e.stopPropagation();
|
|
4684
|
+
if (act === 'leave') leaveCodeEdit(s);
|
|
4685
|
+
else if (act === 'save') commitCodeEditAndReenter(s, { save: true }).catch(showUnexpectedFailure);
|
|
4686
|
+
else if (act === 'undo' || act === 'redo') stepCodeEditHistory(s, act === 'undo' ? -1 : 1);
|
|
4687
|
+
else codeEditKeyEdit(s, act);
|
|
4688
|
+
}
|
|
4689
|
+
// v3.17.0 T9a (M-f): every leave (Esc, Ctrl/Cmd+Enter, unchanged or not)
|
|
4690
|
+
// hands the caret on once it lands, as the raw box's does. T9b: resolves
|
|
4691
|
+
// to whether the leave landed (a held Ctrl+S after it saves then).
|
|
4692
|
+
function leaveCodeEdit(s) {
|
|
4693
|
+
const tail = lines.length - s.endLine;
|
|
4694
|
+
return commitCodeEdit(s).then((ok) => { if (ok) handCaretOnBelow(tail, s.blockEl); return ok; })
|
|
4695
|
+
.catch((e) => { showUnexpectedFailure(e); return false; });
|
|
4696
|
+
}
|
|
4697
|
+
// Plain text only, in both contenteditable modes; a paste is one step.
|
|
4698
|
+
function onCodeEditPaste(s, e) {
|
|
4699
|
+
e.preventDefault();
|
|
4700
|
+
e.stopPropagation();
|
|
4701
|
+
const dt = e.clipboardData || window.clipboardData;
|
|
4702
|
+
const text = dt ? dt.getData('text/plain') : '';
|
|
4703
|
+
if (text) codeEditKeyEdit(s, 'insert', text.replace(/\r\n?/g, '\n'));
|
|
4704
|
+
}
|
|
4705
|
+
// D1's 'true' fallback lets markup in where plaintext-only would not: no
|
|
4706
|
+
// formatting command (in either mode), and a dropped text arrives as plain
|
|
4707
|
+
// text at the drop point. A line break from anywhere but the Enter key (a soft keyboard)
|
|
4708
|
+
// is the Enter edit. A dropped FILE is the document's drop handler's, which
|
|
4709
|
+
// inserts it below the block as it did before (R43).
|
|
4710
|
+
function onCodeEditBeforeInput(s, e) {
|
|
4711
|
+
const t = e.inputType || '';
|
|
4712
|
+
if (/^format/.test(t)) { e.preventDefault(); return; }
|
|
4713
|
+
// The browser's own undo (a menu, a gesture) steps the edit's history
|
|
4714
|
+
// too, never the native stack a re-colour has emptied.
|
|
4715
|
+
if (t === 'historyUndo' || t === 'historyRedo') {
|
|
4716
|
+
e.preventDefault();
|
|
4717
|
+
stepCodeEditHistory(s, t === 'historyUndo' ? -1 : 1);
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
if (t === 'insertParagraph' || t === 'insertLineBreak') {
|
|
4721
|
+
e.preventDefault();
|
|
4722
|
+
codeEditKeyEdit(s, 'enter');
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
if (s.plainOnly || t !== 'insertFromDrop') return;
|
|
4726
|
+
e.preventDefault();
|
|
4727
|
+
const text = e.dataTransfer ? e.dataTransfer.getData('text/plain') : '';
|
|
4728
|
+
const ranges = typeof e.getTargetRanges === 'function' ? e.getTargetRanges() : [];
|
|
4729
|
+
if (ranges.length) {
|
|
4730
|
+
window.getSelection().setBaseAndExtent(ranges[0].startContainer, ranges[0].startOffset,
|
|
4731
|
+
ranges[0].endContainer, ranges[0].endOffset);
|
|
4732
|
+
}
|
|
4733
|
+
if (text) codeEditKeyEdit(s, 'insert', text.replace(/\r\n?/g, '\n'));
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
// R41: the caret under a click point, as an offset in the host's text, or
|
|
4737
|
+
// null when the point is not over it.
|
|
4738
|
+
function codeCaretFromPoint(host, x, y) {
|
|
4739
|
+
let node = null;
|
|
4740
|
+
let off = 0;
|
|
4741
|
+
if (typeof document.caretPositionFromPoint === 'function') {
|
|
4742
|
+
const p = document.caretPositionFromPoint(x, y);
|
|
4743
|
+
if (p) { node = p.offsetNode; off = p.offset; }
|
|
4744
|
+
} else if (typeof document.caretRangeFromPoint === 'function') {
|
|
4745
|
+
const r = document.caretRangeFromPoint(x, y);
|
|
4746
|
+
if (r) { node = r.startContainer; off = r.startOffset; }
|
|
4747
|
+
}
|
|
4748
|
+
if (!node || !host.contains(node)) return null;
|
|
4749
|
+
return offsetInSurface(host, node, off);
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
// D5 / R41: the browser highlighter loads on the first edit, never
|
|
4753
|
+
// awaited (the caret and focus are already placed); a load that failed is
|
|
4754
|
+
// not asked for again, and the text stays plain.
|
|
4755
|
+
function ensureCodeHighlighter(s) {
|
|
4756
|
+
if (window.md2docHljs || diagramEngineGaveUp['highlight.js']) return;
|
|
4757
|
+
loadDiagramEngine('highlight.js').then(() => {
|
|
4758
|
+
if (liveCodeEdit() === s && !s.composing) recolourCodeEdit(s);
|
|
4759
|
+
}, () => {});
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
// Opens the edit on `blockEl` with the caret at `opts.offset` (default 0)
|
|
4763
|
+
// or under the click point `opts.clientX/clientY`. false when the block is
|
|
4764
|
+
// not a fenced code block (the caller opens the raw box instead); true
|
|
4765
|
+
// otherwise, including when another session could not be resolved (it
|
|
4766
|
+
// stays open with its banner) or the block went away meanwhile (notice).
|
|
4767
|
+
// Nothing is awaited on the common path, so focus and caret are placed in
|
|
4768
|
+
// the same task as the gesture and the first keys land in the code (R41).
|
|
4769
|
+
async function editCodeInPlace(blockEl, opts) {
|
|
4770
|
+
let o = opts || {};
|
|
4771
|
+
if (!isFencedCodeBlock(blockEl)) return false;
|
|
4772
|
+
const open = liveCodeEdit();
|
|
4773
|
+
if (open && open.blockEl === blockEl) { open.host.focus(); return true; }
|
|
4774
|
+
// openRawEditor()'s Task G guard: resolve every other session BEFORE this
|
|
4775
|
+
// one registers, so the focusout that focusing the host raises finds
|
|
4776
|
+
// nothing to resolve (an untouched just-inserted block would otherwise be
|
|
4777
|
+
// cancelled, and discarded, by its own focus move).
|
|
4778
|
+
// v3.17.0 T9b (R57, T5 M5): and openDiagramPanel()'s blur first, for the
|
|
4779
|
+
// same reason. The language chip's press keeps focus where it was, and a
|
|
4780
|
+
// surface that keeps focus can open its burst again after the
|
|
4781
|
+
// resolution (measured: an empty new paragraph), so its focusout when the
|
|
4782
|
+
// host takes focus below resolved this brand-new, untouched edit — closed
|
|
4783
|
+
// in the gesture that opened it. The blur's own focusout starts that
|
|
4784
|
+
// resolution now, and the switchAwayFrom() below joins it. Only when
|
|
4785
|
+
// something outside the block held focus: a caller that arrives with
|
|
4786
|
+
// focus on BODY (the click router after its press, the Ctrl+S re-entry)
|
|
4787
|
+
// takes the path it took before.
|
|
4788
|
+
const ae = document.activeElement;
|
|
4789
|
+
const blurred = !!(ae && ae !== document.body && !blockEl.contains(ae) && typeof ae.blur === 'function');
|
|
4790
|
+
if (blurred) ae.blur();
|
|
4791
|
+
if ((blurred && switching) || (activeEditor && activeEditor.blockEl !== blockEl) ||
|
|
4792
|
+
(currentBurst && currentBurst.blockEl !== blockEl)) {
|
|
4793
|
+
const ok = await switchAwayFrom();
|
|
4794
|
+
if (!ok) return true;
|
|
4795
|
+
if (!blockEl.isConnected) { showError(DROPPED_GESTURE_MESSAGE, null, null); return true; }
|
|
4796
|
+
if (!isFencedCodeBlock(blockEl)) return false;
|
|
4797
|
+
if (o.clientX !== undefined) o = { offset: 0 }; // that render may have moved the block under the point
|
|
4798
|
+
}
|
|
4799
|
+
const rec = blockRecOf(blockEl);
|
|
4800
|
+
const source = lines.slice(rec.startLine - 1, rec.endLine).join('\n');
|
|
4801
|
+
const fence = codeEditLib.parseFence(source.split('\n'));
|
|
4802
|
+
const host = blockEl.firstElementChild;
|
|
4803
|
+
const body = fence.body.join('\n');
|
|
4804
|
+
let caret = o.clientX !== undefined ? codeCaretFromPoint(host, o.clientX, o.clientY) : null;
|
|
4805
|
+
if (caret === null) caret = o.offset || 0;
|
|
4806
|
+
caret = Math.max(0, Math.min(body.length, caret));
|
|
4807
|
+
const s = {
|
|
4808
|
+
blockEl, host, fence, lang: fence.info, body, source,
|
|
4809
|
+
blockId: rec.id, startLine: rec.startLine, endLine: rec.endLine,
|
|
4810
|
+
history: new codeEditLib.TextHistory({ text: body, selStart: caret, selEnd: caret }),
|
|
4811
|
+
originalHtml: host.innerHTML, shown: null, timer: null, composing: false,
|
|
4812
|
+
plainOnly: true, inflight: null, listeners: new AbortController(), editor: null,
|
|
4813
|
+
};
|
|
4814
|
+
// R23: the session by value in the raw box's shape, for a reconciliation
|
|
4815
|
+
// to reopen: the whole fenced block as it would be committed.
|
|
4816
|
+
s.editor = {
|
|
4817
|
+
blockEl,
|
|
4818
|
+
hasChanges: () => codeEditText(s) !== s.body || s.lang !== s.fence.info,
|
|
4819
|
+
snapshot: () => codeEditSnapshot(s),
|
|
4820
|
+
commitNow: () => commitCodeEdit(s),
|
|
4821
|
+
// D3: leaving keeps what was typed. resolveOpenSession() calls this only
|
|
4822
|
+
// when nothing changed, which is the commit's own cancel path.
|
|
4823
|
+
cancelNow: () => commitCodeEdit(s),
|
|
4824
|
+
reanchor: () => reanchorCodeEdit(s),
|
|
4825
|
+
};
|
|
4826
|
+
s.commit = () => commitCodeEdit(s);
|
|
4827
|
+
s.end = () => endCodeEditVisual(s);
|
|
4828
|
+
s.commitWithLang = (lang) => commitCodeEditWithLang(s, lang);
|
|
4829
|
+
activeEditor = s.editor;
|
|
4830
|
+
codeEditState = s;
|
|
4831
|
+
host.setAttribute('contenteditable', 'plaintext-only');
|
|
4832
|
+
if (host.contentEditable !== 'plaintext-only') {
|
|
4833
|
+
host.setAttribute('contenteditable', 'true');
|
|
4834
|
+
s.plainOnly = false;
|
|
4835
|
+
}
|
|
4836
|
+
host.setAttribute('data-ed-code-edit', '');
|
|
4837
|
+
host.setAttribute('spellcheck', 'false');
|
|
4838
|
+
// The rendered body is kept when it already is exactly the text (no
|
|
4839
|
+
// flash of uncoloured code while the highlighter loads).
|
|
4840
|
+
const code = host.firstElementChild;
|
|
4841
|
+
if (host.childNodes.length === 1 && code && code.nodeName === 'CODE' &&
|
|
4842
|
+
code.textContent === body && body.charAt(body.length - 1) !== '\n') s.shown = body;
|
|
4843
|
+
else writeCodeEditHost(s, body);
|
|
4844
|
+
const on = { signal: s.listeners.signal };
|
|
4845
|
+
host.addEventListener('keydown', (e) => onCodeEditKeydown(s, e), on);
|
|
4846
|
+
host.addEventListener('input', () => { scheduleCodeEditTick(s); setDirty(); }, on);
|
|
4847
|
+
host.addEventListener('compositionstart', () => { s.composing = true; }, on);
|
|
4848
|
+
host.addEventListener('compositionend', () => { s.composing = false; scheduleCodeEditTick(s); }, on);
|
|
4849
|
+
host.addEventListener('paste', (e) => onCodeEditPaste(s, e), on);
|
|
4850
|
+
host.addEventListener('beforeinput', (e) => onCodeEditBeforeInput(s, e), on);
|
|
4851
|
+
host.focus({ preventScroll: o.clientX !== undefined || !!o.keepScroll });
|
|
4852
|
+
placeCodeEditSelection(s, caret, caret);
|
|
4853
|
+
ensureCodeHighlighter(s);
|
|
4854
|
+
showCodeChipsFor(blockEl, true);
|
|
4855
|
+
return true;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
function codeEditSnapshot(s) {
|
|
4859
|
+
const text = codeEditText(s);
|
|
4860
|
+
const sel = codeEditSel(s);
|
|
4861
|
+
const built = codeEditLib.buildFence({ indent: s.fence.indent, char: s.fence.char, open: s.fence.open,
|
|
4862
|
+
info: s.lang, body: text.split('\n') });
|
|
4863
|
+
return { blockId: s.blockId, range: null, startLine: s.startLine, endLine: s.endLine, source: s.source,
|
|
4864
|
+
value: built.join('\n'), caret: built[0].length + 1 + (sel ? sel[0] : text.length) };
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
// openRawEditor()'s reanchor(), for a session a patch render kept on
|
|
4868
|
+
// screen: the block's id and lines again from the new `blocks`, only while
|
|
4869
|
+
// its lines are still exactly what the edit opened on. Otherwise the edit
|
|
4870
|
+
// ends visually as well (R27 (7)): the caller drops `activeEditor`, and a
|
|
4871
|
+
// host left contenteditable would be an editor nobody resolves.
|
|
4872
|
+
function reanchorCodeEdit(s) {
|
|
4873
|
+
const raw = s.blockEl.getAttribute('data-block-id');
|
|
4874
|
+
const b = raw === null ? null : blocks.find((x) => x.id === Number(raw));
|
|
4875
|
+
if (b && b.endLine >= b.startLine && lines.slice(b.startLine - 1, b.endLine).join('\n') === s.source) {
|
|
4876
|
+
s.blockId = b.id;
|
|
4877
|
+
s.startLine = b.startLine;
|
|
4878
|
+
s.endLine = b.endLine;
|
|
4879
|
+
return true;
|
|
4880
|
+
}
|
|
4881
|
+
endCodeEditVisual(s);
|
|
4882
|
+
return false;
|
|
4883
|
+
}
|
|
4884
|
+
|
|
4885
|
+
// Ends the edit on screen. `activeEditor` is cleared FIRST: taking the
|
|
4886
|
+
// attributes off and writing the host can blur it, and the focusout handler
|
|
4887
|
+
// must find no session to resolve (R1's order, as in restore() above). A
|
|
4888
|
+
// host still on screen gets its rendered HTML from the open back — a node
|
|
4889
|
+
// the patch kept is byte-identical by construction (R27 (6)).
|
|
4890
|
+
function endCodeEditVisual(s) {
|
|
4891
|
+
if (activeEditor === s.editor) activeEditor = null;
|
|
4892
|
+
if (codeEditState === s) codeEditState = null;
|
|
4893
|
+
disposeCodeEdit(s);
|
|
4894
|
+
hideCodeChips();
|
|
4895
|
+
showCodeChipsUnderPointer();
|
|
4896
|
+
const host = s.host;
|
|
4897
|
+
if (host.isConnected && host.hasAttribute('data-ed-code-edit')) {
|
|
4898
|
+
host.removeAttribute('contenteditable');
|
|
4899
|
+
host.removeAttribute('data-ed-code-edit');
|
|
4900
|
+
host.removeAttribute('spellcheck');
|
|
4901
|
+
host.innerHTML = s.originalHtml;
|
|
4902
|
+
}
|
|
4903
|
+
setDirty(); // R47: an edit left unchanged reads clean again
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
// The cancel path: nothing to write. An untouched pristine insert (the
|
|
4907
|
+
// "/" / + / toolbar skeleton) is taken away again, as the raw box's
|
|
4908
|
+
// cancelAndMaybeDiscard() does.
|
|
4909
|
+
async function cancelCodeEdit(s) {
|
|
4910
|
+
const wasPristine = !!(pristineInsert && pristineInsert.blockId === s.blockId);
|
|
4911
|
+
if (wasPristine) pristineInsert = null;
|
|
4912
|
+
endCodeEditVisual(s);
|
|
4913
|
+
if (wasPristine) return await discardPristineInsert();
|
|
4914
|
+
return true;
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
// One commit at a time per session: a second Esc while the render is out
|
|
4918
|
+
// joins the first.
|
|
4919
|
+
function runCodeEditCommit(s) {
|
|
4920
|
+
if (!s.inflight) {
|
|
4921
|
+
s.inflight = commitCodeEditBody(s).then((r) => { s.lastResult = r; return r; })
|
|
4922
|
+
.finally(() => { s.inflight = null; });
|
|
4923
|
+
}
|
|
4924
|
+
return s.inflight;
|
|
4925
|
+
}
|
|
4926
|
+
async function commitCodeEdit(s) {
|
|
4927
|
+
return (await runCodeEditCommit(s)) !== false;
|
|
4928
|
+
}
|
|
4929
|
+
// true, false (the render failed: rolled back, the edit stays open on its
|
|
4930
|
+
// text, the banner is up) or RENDER_SUPERSEDED.
|
|
4931
|
+
async function commitCodeEditBody(s) {
|
|
4932
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
4933
|
+
const text = codeEditText(s);
|
|
4934
|
+
// R37: "unchanged" compares the body and the info string with what was
|
|
4935
|
+
// parsed at the open, never rebuilt lines (an unterminated fence rebuilds
|
|
4936
|
+
// with its closing line, which is a repair only when something changed).
|
|
4937
|
+
// Fix round 1 (R49, M1): asked BEFORE the reconcile guard, like the raw
|
|
4938
|
+
// box's cancel path — leaving an untouched edit writes nothing, so it
|
|
4939
|
+
// never starts a reconciliation that would reopen it as a raw box.
|
|
4940
|
+
if (text === s.body && s.lang === s.fence.info) return await cancelCodeEdit(s);
|
|
4941
|
+
if (reconcileBlocksCommits()) {
|
|
4942
|
+
if (!reconcilePromise) startReconcileRender();
|
|
4943
|
+
return false;
|
|
4944
|
+
}
|
|
4945
|
+
// D10: the fence char and the info string are kept; the fence grows past
|
|
4946
|
+
// the longest run of its char in the body.
|
|
4947
|
+
const built = codeEditLib.buildFence({ indent: s.fence.indent, char: s.fence.char, open: s.fence.open,
|
|
4948
|
+
info: s.lang, body: text.split('\n') });
|
|
4949
|
+
const r = await commitFencedRangeAndRender(s.startLine, s.endLine, built.join('\n'));
|
|
4950
|
+
if (r === null) return await cancelCodeEdit(s);
|
|
4951
|
+
if (r === false || r === RENDER_SUPERSEDED) return r;
|
|
4952
|
+
endCodeEditVisual(s);
|
|
4953
|
+
return true;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// R27 (2)-(4), shared by the in-place code edit and the diagram panel (T5):
|
|
4957
|
+
// ONE commitRangeEdit over the block's lines and its render. null: nothing
|
|
4958
|
+
// changed (no op, nothing pushed; the caller takes its cancel path). false:
|
|
4959
|
+
// the render failed, the op is rolled back and the caller stays open on its
|
|
4960
|
+
// text. RENDER_SUPERSEDED: not success yet, the caller touches no DOM. true:
|
|
4961
|
+
// rendered.
|
|
4962
|
+
async function commitFencedRangeAndRender(startLine, endLine, text) {
|
|
4963
|
+
const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text);
|
|
4964
|
+
if (result.op === null) return null;
|
|
4965
|
+
const prevLines = lines;
|
|
4966
|
+
lines = result.lines;
|
|
4967
|
+
const editRange = { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4968
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4969
|
+
delta: result.op.after.length - result.op.before.length };
|
|
4970
|
+
const ok = await safeRerenderAll(editRange);
|
|
4971
|
+
if (!ok) {
|
|
4972
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4973
|
+
return false;
|
|
4974
|
+
}
|
|
4975
|
+
return ok === RENDER_SUPERSEDED ? RENDER_SUPERSEDED : true;
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4978
|
+
// D7 / R11: the text and the new language as ONE op, then the edit opens
|
|
4979
|
+
// again on the re-rendered block at the same caret offset. T3's language
|
|
4980
|
+
// chip calls this.
|
|
4981
|
+
function commitCodeEditWithLang(s, lang) {
|
|
4982
|
+
return commitCodeEditAndReenter(s, { lang });
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
// Keys typed while commitCodeEditAndReenter()'s render is out, or null.
|
|
4986
|
+
// The host they would go into is the one that render replaces, and focus is
|
|
4987
|
+
// on BODY between the swap and the re-entry, so they are held here —
|
|
4988
|
+
// whatever the target — and replayed into the edit that opens again.
|
|
4989
|
+
// v3.17.0 T9b: until a press puts the caret in a text surface elsewhere
|
|
4990
|
+
// (`clickedAway`, the v3.13.1 rule: an entry a click positioned elsewhere
|
|
4991
|
+
// is not the edit's). From then on nothing is held — keys go where that
|
|
4992
|
+
// press put the caret — and the edit is not opened again over it.
|
|
4993
|
+
let codeEditReenterHold = null; // { keys: [{ kind, data?, key?, … }], clickedAway, passTarget? }
|
|
4994
|
+
|
|
4995
|
+
// R48 (Ctrl+S) and R11 (a language pick): commit through switchAwayFrom()
|
|
4996
|
+
// (the same gate a click elsewhere uses), open the edit again on the block
|
|
4997
|
+
// at the same start line with the caret where it was, put back what was
|
|
4998
|
+
// typed meanwhile, then save when asked. A failed render leaves the edit
|
|
4999
|
+
// open (R27 (3)), and the held keys go into it; a superseded one leaves no
|
|
5000
|
+
// block to open on, so the held keys are dropped with the notice.
|
|
5001
|
+
// v3.17.0 T9b: a held Ctrl+S is a key in that order like the others
|
|
5002
|
+
// (Task 2 re-review: as a flag it reopened the edit after a held Esc).
|
|
5003
|
+
// Ahead of any leave it saves what was typed before it: this runs again on
|
|
5004
|
+
// the reopened edit, `opts.carry` taking the keys after it there. After a
|
|
5005
|
+
// leave it saves once the leave has landed, and the edit stays closed.
|
|
5006
|
+
// Every save from here goes after the one already out
|
|
5007
|
+
// (saveAfterLastSave()), never alongside it. A press that put the caret in
|
|
5008
|
+
// another text surface while the render was out (`clickedAway`) ends the
|
|
5009
|
+
// re-entry: nothing opens again or takes focus back, and keys held before
|
|
5010
|
+
// that press have nowhere to go (the notice).
|
|
5011
|
+
async function commitCodeEditAndReenter(s, opts) {
|
|
5012
|
+
const sel = codeEditSel(s);
|
|
5013
|
+
const caret = sel ? sel[0] : codeEditText(s).length;
|
|
5014
|
+
const startLine = s.startLine;
|
|
5015
|
+
if (opts.lang !== undefined) s.lang = codeEditLib.setFenceLang(s.lang, opts.lang);
|
|
5016
|
+
const hold = { keys: [], clickedAway: false };
|
|
5017
|
+
codeEditReenterHold = hold;
|
|
5018
|
+
let ok;
|
|
5019
|
+
try {
|
|
5020
|
+
ok = await switchAwayFrom();
|
|
5021
|
+
} finally {
|
|
5022
|
+
if (codeEditReenterHold === hold) codeEditReenterHold = null;
|
|
5023
|
+
}
|
|
5024
|
+
const keys = (opts.carry || []).concat(hold.keys);
|
|
5025
|
+
let target = liveCodeEdit() === s ? s : null;
|
|
5026
|
+
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
|
|
5027
|
+
const el = blockElAtLine(startLine);
|
|
5028
|
+
if (el && await editCodeInPlace(el, { offset: caret, keepScroll: true })) target = liveCodeEdit();
|
|
5029
|
+
}
|
|
5030
|
+
if (ok && opts.save) saveAfterLastSave();
|
|
5031
|
+
if (!target || hold.clickedAway) {
|
|
5032
|
+
if (keys.some((k) => k.kind !== 'save')) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
5033
|
+
if (ok && keys.some((k) => k.kind === 'save')) saveAfterLastSave();
|
|
5034
|
+
return !!ok;
|
|
5035
|
+
}
|
|
5036
|
+
const next = replayIntoCodeEdit(target, keys);
|
|
5037
|
+
if (next.rest) return commitCodeEditAndReenter(target, { save: true, carry: next.rest });
|
|
5038
|
+
if (next.leave) {
|
|
5039
|
+
const left = leaveCodeEdit(target);
|
|
5040
|
+
if (next.saveAfterLeave) left.then((landed) => { if (landed) saveAfterLastSave(); });
|
|
5041
|
+
}
|
|
5042
|
+
return !!ok;
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
// The hold itself: capture phase on the document, ahead of every other
|
|
5046
|
+
// keydown listener (the host's own included), so a held key changes
|
|
5047
|
+
// nothing on its way. Text keys, Enter, Tab, Backspace, caret moves and a
|
|
5048
|
+
// second Ctrl+S are kept in order; Esc / Ctrl+Enter leave after the replay;
|
|
5049
|
+
// anything else is dropped with the notice. An IME composition is not held
|
|
5050
|
+
// (its keydowns are isComposing): known limit.
|
|
5051
|
+
function wireCodeEditReenterHold() {
|
|
5052
|
+
// v3.17.0 T9b: a press on a text surface (a paragraph, heading, list
|
|
5053
|
+
// item or cell) puts the caret there; the hold stops at it. A press on
|
|
5054
|
+
// anything else (the margin, chrome, a quote or code block) does not
|
|
5055
|
+
// place a caret, and the keys stay held for the edit.
|
|
5056
|
+
document.addEventListener('mousedown', (e) => {
|
|
5057
|
+
const hold = codeEditReenterHold;
|
|
5058
|
+
if (!hold || hold.clickedAway || e.button !== 0) return;
|
|
5059
|
+
const t = e.target;
|
|
5060
|
+
if (t && t.closest && t.closest('.ed-wys-armed, .ed-wys-cell')) hold.clickedAway = true;
|
|
5061
|
+
}, true);
|
|
5062
|
+
document.addEventListener('keydown', (e) => {
|
|
5063
|
+
const hold = codeEditReenterHold;
|
|
5064
|
+
if (!hold || hold.clickedAway || e.isComposing || e.keyCode === 229) return;
|
|
5065
|
+
// v3.17.0 T5: the diagram panel's textarea stays on screen, focused,
|
|
5066
|
+
// while its Ctrl+S render is out; what is typed there is its own (the
|
|
5067
|
+
// panel carries it over). Only keys that land elsewhere are held.
|
|
5068
|
+
if (hold.passTarget && e.target === hold.passTarget) return;
|
|
5069
|
+
if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') return;
|
|
5070
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
5071
|
+
let entry;
|
|
5072
|
+
if (e.key === 'Escape' || (e.key === 'Enter' && mod)) entry = { kind: 'leave', esc: e.key === 'Escape' };
|
|
5073
|
+
else if (mod && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) entry = { kind: 'save' };
|
|
5074
|
+
else if (e.key === 'Enter' && !e.altKey) entry = { kind: 'enter' };
|
|
5075
|
+
else if (e.key === 'Tab' && !mod && !e.altKey) entry = { kind: e.shiftKey ? 'dedent' : 'tab' };
|
|
5076
|
+
else if (e.key === 'Backspace' && !mod && !e.altKey) entry = { kind: 'backspace' };
|
|
5077
|
+
else if (e.key.length === 1 && !mod && !e.altKey) entry = { kind: 'insert', data: e.key };
|
|
5078
|
+
else if (caretMoveForKey(e)) {
|
|
5079
|
+
entry = { kind: 'move', key: e.key, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey };
|
|
5080
|
+
} else entry = { kind: 'drop' };
|
|
5081
|
+
e.preventDefault();
|
|
5082
|
+
e.stopImmediatePropagation();
|
|
5083
|
+
if (entry) hold.keys.push(entry);
|
|
5084
|
+
}, true);
|
|
5085
|
+
}
|
|
5086
|
+
// The held keys into the reopened edit, in order, up to the first held
|
|
5087
|
+
// Ctrl+S ahead of any leave: `rest` is what came after it (null when there
|
|
5088
|
+
// was none), for the caller's next round. The caller runs the leave
|
|
5089
|
+
// (`leave`), and a Ctrl+S held after it (`saveAfterLeave`) saves once it
|
|
5090
|
+
// has landed.
|
|
5091
|
+
function replayIntoCodeEdit(s, keys) {
|
|
5092
|
+
let changed = false;
|
|
5093
|
+
let dropped = false;
|
|
5094
|
+
let leave = false;
|
|
5095
|
+
let saveAfterLeave = false;
|
|
5096
|
+
let rest = null;
|
|
5097
|
+
for (let i = 0; i < keys.length; i++) {
|
|
5098
|
+
const k = keys[i];
|
|
5099
|
+
if (k.kind === 'save') {
|
|
5100
|
+
if (leave) { saveAfterLeave = true; continue; }
|
|
5101
|
+
rest = keys.slice(i + 1);
|
|
5102
|
+
break;
|
|
5103
|
+
}
|
|
5104
|
+
if (leave) { dropped = true; continue; }
|
|
5105
|
+
if (k.kind === 'leave') { leave = true; continue; }
|
|
5106
|
+
if (k.kind === 'drop') { dropped = true; continue; }
|
|
5107
|
+
if (k.kind === 'move') {
|
|
5108
|
+
const mv = caretMoveForKey(k);
|
|
5109
|
+
const sel = window.getSelection();
|
|
5110
|
+
if (mv && sel.modify) sel.modify(mv.extend ? 'extend' : 'move', mv.dir, mv.unit);
|
|
5111
|
+
continue;
|
|
5112
|
+
}
|
|
5113
|
+
const r = codeEditEditResult(s, k.kind, k.data);
|
|
5114
|
+
setCodeEditText(s, r.text, r.selStart, r.selEnd);
|
|
5115
|
+
changed = true;
|
|
5116
|
+
}
|
|
5117
|
+
if (changed) pushCodeEditHistory(s);
|
|
5118
|
+
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
5119
|
+
return { leave, saveAfterLeave, rest };
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
// R28: the keys held while the ``` shortcut's render was out (typed into
|
|
5123
|
+
// the paragraph it replaced) go into the new edit, in order, before the
|
|
5124
|
+
// gesture's flight ends: text as typed, Enter as a newline with the line's
|
|
5125
|
+
// indent, Backspace as one character back. A held Escape or Ctrl/Cmd+Enter
|
|
5126
|
+
// (the `rawBoxOnly` entries, R66 / R68) commits and leaves. The first other
|
|
5127
|
+
// held key stops the drain, and it and the rest of its run are dropped with
|
|
5128
|
+
// the notice; an entry a click positioned elsewhere stops it and is left to
|
|
5129
|
+
// the ordinary flush. One history step for all of it.
|
|
5130
|
+
function drainHeldKeysIntoCodeEdit() {
|
|
5131
|
+
const s = liveCodeEdit();
|
|
5132
|
+
if (!s || document.activeElement !== s.host) return;
|
|
5133
|
+
let changed = false;
|
|
5134
|
+
let leave = false;
|
|
5135
|
+
while (flightBuffer.length) {
|
|
5136
|
+
const en = flightBuffer[0];
|
|
5137
|
+
if (en.pos) break;
|
|
5138
|
+
const plain = en.kind === 'key' && !en.ctrlKey && !en.metaKey && !en.altKey;
|
|
5139
|
+
let r = null;
|
|
5140
|
+
if (en.kind === 'text') r = codeEditEditResult(s, 'insert', en.data);
|
|
5141
|
+
else if (plain && en.key === 'Enter') r = codeEditEditResult(s, 'enter');
|
|
5142
|
+
else if (plain && !en.shiftKey && en.key === 'Backspace') r = codeEditEditResult(s, 'backspace');
|
|
5143
|
+
else if (en.rawBoxOnly) { flightBuffer.shift(); leave = true; break; }
|
|
5144
|
+
else {
|
|
5145
|
+
while (flightBuffer.length && !flightBuffer[0].pos) flightBuffer.shift();
|
|
5146
|
+
showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
5147
|
+
break;
|
|
5148
|
+
}
|
|
5149
|
+
setCodeEditText(s, r.text, r.selStart, r.selEnd);
|
|
5150
|
+
flightBuffer.shift();
|
|
5151
|
+
changed = true;
|
|
5152
|
+
}
|
|
5153
|
+
if (changed) pushCodeEditHistory(s);
|
|
5154
|
+
if (leave) leaveCodeEdit(s);
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
// R25: a target whose keys are its own — the in-place code edit, and the
|
|
5158
|
+
// diagram panel, language menu and image toolbar of the later v3.17.0
|
|
5159
|
+
// tasks. The document keydown handler leaves them alone below Ctrl+S.
|
|
5160
|
+
function ownsOwnKeys(target) {
|
|
5161
|
+
return !!(target && target.closest &&
|
|
5162
|
+
target.closest('[data-ed-code-edit], .ed-diagram-panel, .ed-code-langmenu, .ed-imgtb'));
|
|
5163
|
+
}
|
|
5164
|
+
|
|
5165
|
+
// ── v3.17.0 T3: the language chip and the copy chip (D7–D9, R40) ───────
|
|
5166
|
+
//
|
|
5167
|
+
// One singleton on document.body, never inside .content (anything in there
|
|
5168
|
+
// can end up in the readbacks, the wave button's reason): fixed at the
|
|
5169
|
+
// top-right of the fenced code block the pointer is over, or of the one
|
|
5170
|
+
// being edited. Every press calls preventDefault() on mousedown, so the
|
|
5171
|
+
// edit keeps focus; only the language filter input takes it. A chip click
|
|
5172
|
+
// on a block that is not being edited opens the edit first (focus, caret at
|
|
5173
|
+
// 0), then the menu (R40); picking commits the text and the language as
|
|
5174
|
+
// one op through codeEditState.commitWithLang().
|
|
5175
|
+
const CODE_CHIP_INSET_PX = 6;
|
|
5176
|
+
const CODE_COPY_RESET_MS = 2000;
|
|
5177
|
+
const codeChips = document.createElement('div');
|
|
5178
|
+
codeChips.className = 'ed-code-chips';
|
|
5179
|
+
codeChips.hidden = true;
|
|
5180
|
+
const codeLangBtn = document.createElement('button');
|
|
5181
|
+
codeLangBtn.type = 'button';
|
|
5182
|
+
codeLangBtn.className = 'ed-code-chip ed-code-lang';
|
|
5183
|
+
codeLangBtn.title = '程式碼語言';
|
|
5184
|
+
codeLangBtn.setAttribute('aria-haspopup', 'listbox');
|
|
5185
|
+
codeLangBtn.setAttribute('aria-expanded', 'false');
|
|
5186
|
+
const codeLangLabel = document.createElement('span');
|
|
5187
|
+
codeLangBtn.appendChild(codeLangLabel);
|
|
5188
|
+
const codeCopyBtn = document.createElement('button');
|
|
5189
|
+
codeCopyBtn.type = 'button';
|
|
5190
|
+
codeCopyBtn.className = 'ed-code-chip ed-code-copy';
|
|
5191
|
+
codeChips.appendChild(codeLangBtn);
|
|
5192
|
+
codeChips.appendChild(codeCopyBtn);
|
|
5193
|
+
document.body.appendChild(codeChips);
|
|
5194
|
+
function codeChipIcon(name) { return icons && icons.NAMES.indexOf(name) !== -1 ? icons.svg(name, 14) : ''; }
|
|
5195
|
+
const CODE_COPY_ICON = codeChipIcon('copy');
|
|
5196
|
+
const CODE_COPY_DONE = codeChipIcon('check') + '<span>Copied</span>';
|
|
5197
|
+
const CODE_COPY_KEYS = /Mac|iPhone|iPad/.test(navigator.platform || '') ? 'Press Cmd+C' : 'Press Ctrl+C';
|
|
5198
|
+
codeLangBtn.insertAdjacentHTML('beforeend', codeChipIcon('chevron-down'));
|
|
5199
|
+
let codeChipsBlock = null; // the .ed-block the chips are drawn for
|
|
5200
|
+
let codeLangMenu = null; // { el, input, list, s, sel, items, cursor }
|
|
5201
|
+
let codeCopyTimer = null;
|
|
5202
|
+
|
|
5203
|
+
// Whether the in-place edit is open right now, with no side effect (unlike
|
|
5204
|
+
// liveCodeEdit(), which disposes a session whose host a render detached).
|
|
5205
|
+
function codeEditIsOpen() {
|
|
5206
|
+
const s = codeEditState;
|
|
5207
|
+
return !!s && activeEditor === s.editor && s.host.isConnected;
|
|
5208
|
+
}
|
|
5209
|
+
|
|
5210
|
+
function resetCodeCopyChip() {
|
|
5211
|
+
clearTimeout(codeCopyTimer);
|
|
5212
|
+
codeCopyTimer = null;
|
|
5213
|
+
codeCopyBtn.classList.remove('is-copied');
|
|
5214
|
+
codeCopyBtn.innerHTML = CODE_COPY_ICON;
|
|
5215
|
+
codeCopyBtn.setAttribute('aria-label', 'Copy code');
|
|
5216
|
+
codeCopyBtn.title = 'Copy code';
|
|
5217
|
+
}
|
|
5218
|
+
resetCodeCopyChip();
|
|
5219
|
+
|
|
5220
|
+
// The info string the chip shows for `blockEl`: the open edit's (a language
|
|
5221
|
+
// picked meanwhile counts), else the block's own fence line.
|
|
5222
|
+
function codeChipInfo(blockEl) {
|
|
5223
|
+
if (codeEditIsOpen() && codeEditState.blockEl === blockEl) return codeEditState.lang;
|
|
5224
|
+
const rec = blockRecOf(blockEl);
|
|
5225
|
+
if (!rec || rec.endLine < rec.startLine) return null;
|
|
5226
|
+
const f = codeEditLib.parseFence(lines.slice(rec.startLine - 1, rec.endLine));
|
|
5227
|
+
return f ? f.info : null;
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
function hideCodeChips() {
|
|
5231
|
+
closeCodeLangMenu({ refocus: false });
|
|
5232
|
+
if (codeChips.hidden && codeChipsBlock === null) return;
|
|
5233
|
+
codeChips.hidden = true;
|
|
5234
|
+
codeChipsBlock = null;
|
|
5235
|
+
resetCodeCopyChip();
|
|
5236
|
+
}
|
|
5237
|
+
|
|
5238
|
+
// D9: top = max(toolbarTopInset() + 4, rect.top + 6), a 6px right inset
|
|
5239
|
+
// (the wave button's formula). `right` rather than `left`, so the copy
|
|
5240
|
+
// chip growing to say Copied does not move the right edge.
|
|
5241
|
+
function placeCodeChips() {
|
|
5242
|
+
const pre = codeChipsBlock && codeChipsBlock.firstElementChild;
|
|
5243
|
+
if (!pre) return false;
|
|
5244
|
+
const r = pre.getBoundingClientRect();
|
|
5245
|
+
if (r.width === 0 && r.height === 0) return false;
|
|
5246
|
+
const top = Math.max(toolbarTopInset() + 4, r.top + CODE_CHIP_INSET_PX);
|
|
5247
|
+
// Scrolled until the block's bottom is under the toolbar: nothing to draw on.
|
|
5248
|
+
if (top + codeChips.offsetHeight > r.bottom - 2 || r.top > window.innerHeight) return false;
|
|
5249
|
+
codeChips.style.top = Math.round(top) + 'px';
|
|
5250
|
+
codeChips.style.right = Math.max(4, document.documentElement.clientWidth - r.right + CODE_CHIP_INSET_PX) + 'px';
|
|
5251
|
+
return true;
|
|
5252
|
+
}
|
|
5253
|
+
|
|
5254
|
+
// M6: hiding the chips when an edit ends leaves a pointer that stays over
|
|
5255
|
+
// the block without chips (mouseover only fires on an element change). Once
|
|
5256
|
+
// the DOM has settled, the block under the pointer gets them back.
|
|
5257
|
+
function showCodeChipsUnderPointer() {
|
|
5258
|
+
setTimeout(() => {
|
|
5259
|
+
if (codeEditIsOpen() || !codeChips.hidden) return;
|
|
5260
|
+
const hov = document.querySelectorAll(':hover');
|
|
5261
|
+
const t = hov.length ? hov[hov.length - 1] : null;
|
|
5262
|
+
const blk = t && t.closest ? t.closest('.ed-block[data-block-type="code"]') : null;
|
|
5263
|
+
if (blk) showCodeChipsFor(blk);
|
|
5264
|
+
}, 120);
|
|
5265
|
+
}
|
|
5266
|
+
|
|
5267
|
+
function showCodeChipsFor(blockEl, force) {
|
|
5268
|
+
if (!codeEditLib || !blockEl || !blockEl.isConnected || waveEditorIsModal() ||
|
|
5269
|
+
!isFencedCodeBlock(blockEl) || blockEl.querySelector('.ed-raw')) { hideCodeChips(); return; }
|
|
5270
|
+
if (codeChipsBlock === blockEl && !codeChips.hidden && !force) return;
|
|
5271
|
+
const info = codeChipInfo(blockEl);
|
|
5272
|
+
if (info === null) { hideCodeChips(); return; }
|
|
5273
|
+
if (codeChipsBlock !== blockEl) resetCodeCopyChip();
|
|
5274
|
+
codeChipsBlock = blockEl;
|
|
5275
|
+
codeLangLabel.textContent = codeEditLib.langLabel(info);
|
|
5276
|
+
codeLangBtn.setAttribute('aria-label', '程式碼語言:' + codeLangLabel.textContent);
|
|
5277
|
+
codeChips.hidden = false;
|
|
5278
|
+
if (!placeCodeChips()) hideCodeChips();
|
|
5279
|
+
}
|
|
5280
|
+
|
|
5281
|
+
// After a render: chips for a block that is gone go; the open edit's chips
|
|
5282
|
+
// follow it; a hovered block's chips refresh their label.
|
|
5283
|
+
function reconcileCodeChips() {
|
|
5284
|
+
if (codeLangMenu && !codeEditIsOpen()) closeCodeLangMenu({ refocus: false });
|
|
5285
|
+
if (codeChips.hidden) return;
|
|
5286
|
+
if (codeEditIsOpen()) { showCodeChipsFor(codeEditState.blockEl, true); return; }
|
|
5287
|
+
if (!codeChipsBlock || !codeChipsBlock.isConnected) hideCodeChips();
|
|
5288
|
+
else showCodeChipsFor(codeChipsBlock, true);
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
document.addEventListener('mouseover', (ev) => {
|
|
5292
|
+
const t = ev.target;
|
|
5293
|
+
if (!t || typeof t.closest !== 'function' || waveEditorIsModal()) return;
|
|
5294
|
+
if (t.closest('.ed-code-chips, .ed-code-langmenu')) return;
|
|
5295
|
+
if (codeEditIsOpen()) return; // the edit owns the chips
|
|
5296
|
+
const blk = t.closest('.ed-block[data-block-type="code"]');
|
|
5297
|
+
if (blk) showCodeChipsFor(blk); else hideCodeChips();
|
|
5298
|
+
}, true);
|
|
5299
|
+
// Scroll: the chips are at viewport coordinates, so they follow the open
|
|
5300
|
+
// edit and go otherwise. The menu closes (the "/" menu's rule) except for
|
|
5301
|
+
// its own list scrolling.
|
|
5302
|
+
document.addEventListener('scroll', (e) => {
|
|
5303
|
+
if (codeChips.hidden) return;
|
|
5304
|
+
const t = e.target;
|
|
5305
|
+
if (t && t.nodeType === 1 && t.closest && t.closest('.ed-code-langmenu')) return;
|
|
5306
|
+
// The block's own sideways scroll moves nothing the chips sit on.
|
|
5307
|
+
if (t && t.nodeType === 1 && codeChipsBlock && codeChipsBlock.contains(t)) return;
|
|
5308
|
+
if (codeEditIsOpen()) {
|
|
5309
|
+
if (codeLangMenu) closeCodeLangMenu({ refocus: true });
|
|
5310
|
+
showCodeChipsFor(codeEditState.blockEl, true);
|
|
5311
|
+
} else hideCodeChips();
|
|
5312
|
+
}, { passive: true, capture: true });
|
|
5313
|
+
window.addEventListener('resize', () => {
|
|
5314
|
+
if (codeChips.hidden) return;
|
|
5315
|
+
if (!placeCodeChips()) hideCodeChips();
|
|
5316
|
+
});
|
|
5317
|
+
|
|
5318
|
+
// Presses on the chips and the menu never move focus: the edit keeps it.
|
|
5319
|
+
// The filter input is the one thing that takes it.
|
|
5320
|
+
codeChips.addEventListener('mousedown', (e) => { e.preventDefault(); });
|
|
5321
|
+
|
|
5322
|
+
// ── copy (D8) ──
|
|
5323
|
+
function showCodeCopyResult(ok) {
|
|
5324
|
+
setTimeout(() => drawCodeCopyResult(ok), 0);
|
|
5325
|
+
}
|
|
5326
|
+
function drawCodeCopyResult(ok) {
|
|
5327
|
+
clearTimeout(codeCopyTimer);
|
|
5328
|
+
codeCopyBtn.classList.toggle('is-copied', ok);
|
|
5329
|
+
codeCopyBtn.innerHTML = ok ? CODE_COPY_DONE : '<span>' + CODE_COPY_KEYS + '</span>';
|
|
5330
|
+
codeCopyBtn.setAttribute('aria-label', ok ? 'Copied' : CODE_COPY_KEYS);
|
|
5331
|
+
codeCopyBtn.title = ok ? 'Copied' : CODE_COPY_KEYS;
|
|
5332
|
+
codeCopyTimer = setTimeout(resetCodeCopyChip, CODE_COPY_RESET_MS);
|
|
5333
|
+
if (codeChipsBlock) placeCodeChips();
|
|
5334
|
+
}
|
|
5335
|
+
// The code the copy chip hands over: the edit's current text (typing that
|
|
5336
|
+
// is not committed yet included), else the block's own body.
|
|
5337
|
+
function codeChipText(blockEl) {
|
|
5338
|
+
if (codeEditIsOpen() && codeEditState.blockEl === blockEl) return codeEditText(codeEditState);
|
|
5339
|
+
const rec = blockRecOf(blockEl);
|
|
5340
|
+
const f = rec ? codeEditLib.parseFence(lines.slice(rec.startLine - 1, rec.endLine)) : null;
|
|
5341
|
+
return f ? f.body.join('\n') : null;
|
|
5342
|
+
}
|
|
5343
|
+
function copyCodeFromChip() {
|
|
5344
|
+
const blockEl = codeChipsBlock;
|
|
5345
|
+
if (!blockEl || !blockEl.isConnected) { hideCodeChips(); return; }
|
|
5346
|
+
const text = codeChipText(blockEl);
|
|
5347
|
+
if (text === null) return;
|
|
5348
|
+
// Refused: select the whole code and say which keys to press.
|
|
5349
|
+
function bySelection() {
|
|
5350
|
+
const s = codeEditIsOpen() && codeEditState.blockEl === blockEl ? codeEditState : null;
|
|
5351
|
+
let ok = false;
|
|
5352
|
+
if (s) placeCodeEditSelection(s, 0, codeEditText(s).length);
|
|
5353
|
+
else {
|
|
5354
|
+
const code = blockEl.querySelector('pre > code');
|
|
5355
|
+
if (code) {
|
|
5356
|
+
const range = document.createRange();
|
|
5357
|
+
range.selectNodeContents(code);
|
|
5358
|
+
const sel = window.getSelection();
|
|
5359
|
+
sel.removeAllRanges();
|
|
5360
|
+
sel.addRange(range);
|
|
5361
|
+
}
|
|
5362
|
+
}
|
|
5363
|
+
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
|
|
5364
|
+
if (ok) window.getSelection().removeAllRanges();
|
|
5365
|
+
showCodeCopyResult(ok);
|
|
5366
|
+
}
|
|
5367
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
5368
|
+
navigator.clipboard.writeText(text).then(() => showCodeCopyResult(true), bySelection);
|
|
5369
|
+
} else bySelection();
|
|
5370
|
+
}
|
|
5371
|
+
// The result is drawn from a timer (showCodeCopyResult), never inside the
|
|
5372
|
+
// click's dispatch: swapping the chip's children there would detach the
|
|
5373
|
+
// click's target before it bubbles, and the click router would read it as a
|
|
5374
|
+
// click outside every block.
|
|
5375
|
+
codeCopyBtn.addEventListener('click', copyCodeFromChip);
|
|
5376
|
+
|
|
5377
|
+
// ── language menu (D7) ──
|
|
5378
|
+
// The CODE_LANGS entry id an info string resolves to ('' for none), or null
|
|
5379
|
+
// for a word the table does not know.
|
|
5380
|
+
function codeLangIdOf(info) {
|
|
5381
|
+
const word = String(info || '').trim().split(/\s+/)[0].toLowerCase();
|
|
5382
|
+
if (!word) return '';
|
|
5383
|
+
const hit = codeEditLib.CODE_LANGS.find((l) => (l.id && l.id === word) || l.aliases.indexOf(word) !== -1);
|
|
5384
|
+
return hit ? hit.id : null;
|
|
5385
|
+
}
|
|
5386
|
+
function markCodeLangCursor(m) {
|
|
5387
|
+
const cur = m.items[m.cursor];
|
|
5388
|
+
m.list.querySelectorAll('.ed-code-langitem').forEach((b) => {
|
|
5389
|
+
const on = !!cur && b.getAttribute('data-ed-lang-id') === cur.id;
|
|
5390
|
+
if (on) {
|
|
5391
|
+
b.setAttribute('data-ed-lang-cursor', '');
|
|
5392
|
+
m.input.setAttribute('aria-activedescendant', b.id);
|
|
5393
|
+
b.scrollIntoView({ block: 'nearest' });
|
|
5394
|
+
} else b.removeAttribute('data-ed-lang-cursor');
|
|
5395
|
+
});
|
|
5396
|
+
}
|
|
5397
|
+
function renderCodeLangItems(m) {
|
|
5398
|
+
m.items = codeEditLib.filterLangs(m.input.value);
|
|
5399
|
+
const current = codeLangIdOf(m.s.lang);
|
|
5400
|
+
const at = m.items.findIndex((l) => l.id === current);
|
|
5401
|
+
m.cursor = at === -1 || m.input.value.trim() !== '' ? 0 : at;
|
|
5402
|
+
m.list.textContent = '';
|
|
5403
|
+
if (!m.items.length) {
|
|
5404
|
+
const none = document.createElement('div');
|
|
5405
|
+
none.className = 'ed-code-langempty';
|
|
5406
|
+
none.textContent = '沒有符合的語言';
|
|
5407
|
+
m.list.appendChild(none);
|
|
5408
|
+
// v3.17.0 T10 (R54c): no option is current, so the input points at none.
|
|
5409
|
+
m.input.removeAttribute('aria-activedescendant');
|
|
5410
|
+
return;
|
|
5411
|
+
}
|
|
5412
|
+
m.items.forEach((l) => {
|
|
5413
|
+
const b = document.createElement('button');
|
|
5414
|
+
b.type = 'button';
|
|
5415
|
+
b.className = 'ed-code-langitem';
|
|
5416
|
+
b.setAttribute('role', 'option');
|
|
5417
|
+
b.setAttribute('tabindex', '-1');
|
|
5418
|
+
b.setAttribute('data-ed-lang-id', l.id);
|
|
5419
|
+
b.id = 'ed-code-lang-' + (l.id || 'text');
|
|
5420
|
+
b.setAttribute('aria-selected', l.id === current ? 'true' : 'false');
|
|
5421
|
+
const label = document.createElement('span');
|
|
5422
|
+
label.textContent = l.label;
|
|
5423
|
+
b.appendChild(label);
|
|
5424
|
+
if (l.id === current) b.insertAdjacentHTML('beforeend', codeChipIcon('check'));
|
|
5425
|
+
b.addEventListener('click', () => { pickCodeLang(m, l.id); });
|
|
5426
|
+
m.list.appendChild(b);
|
|
5427
|
+
});
|
|
5428
|
+
markCodeLangCursor(m);
|
|
5429
|
+
}
|
|
5430
|
+
function placeCodeLangMenu(m) {
|
|
5431
|
+
const b = codeLangBtn.getBoundingClientRect();
|
|
5432
|
+
const w = m.el.offsetWidth;
|
|
5433
|
+
const h = m.el.offsetHeight;
|
|
5434
|
+
const vw = document.documentElement.clientWidth || window.innerWidth;
|
|
5435
|
+
let left = Math.min(b.left, vw - w - SLASH_EDGE_PX);
|
|
5436
|
+
left = Math.max(SLASH_EDGE_PX, left);
|
|
5437
|
+
let top = b.bottom + SLASH_GAP_PX;
|
|
5438
|
+
if (top + h > window.innerHeight - SLASH_GAP_PX) {
|
|
5439
|
+
top = Math.max(toolbarTopInset() + SLASH_GAP_PX, b.top - SLASH_GAP_PX - h);
|
|
5440
|
+
}
|
|
5441
|
+
m.el.style.left = Math.round(left) + 'px';
|
|
5442
|
+
m.el.style.top = Math.round(top) + 'px';
|
|
5443
|
+
}
|
|
5444
|
+
function openCodeLangMenu(s) {
|
|
5445
|
+
closeCodeLangMenu({ refocus: false });
|
|
5446
|
+
const el = document.createElement('div');
|
|
5447
|
+
el.className = 'ed-code-langmenu';
|
|
5448
|
+
const input = document.createElement('input');
|
|
5449
|
+
input.type = 'text';
|
|
5450
|
+
input.className = 'ed-code-langfilter';
|
|
5451
|
+
input.placeholder = '篩選語言';
|
|
5452
|
+
input.setAttribute('aria-label', '篩選語言');
|
|
5453
|
+
input.setAttribute('role', 'combobox');
|
|
5454
|
+
input.setAttribute('aria-expanded', 'true');
|
|
5455
|
+
input.setAttribute('aria-controls', 'ed-code-langlist');
|
|
5456
|
+
input.setAttribute('autocomplete', 'off');
|
|
5457
|
+
input.spellcheck = false;
|
|
5458
|
+
const list = document.createElement('div');
|
|
5459
|
+
list.className = 'ed-code-langlist';
|
|
5460
|
+
list.setAttribute('role', 'listbox');
|
|
5461
|
+
list.id = 'ed-code-langlist';
|
|
5462
|
+
el.appendChild(input);
|
|
5463
|
+
el.appendChild(list);
|
|
5464
|
+
// The caret is read BEFORE focus moves to the filter: a pick puts it back.
|
|
5465
|
+
const m = { el, input, list, s, sel: codeEditSel(s), items: [], cursor: 0 };
|
|
5466
|
+
codeLangMenu = m;
|
|
5467
|
+
document.body.appendChild(el);
|
|
5468
|
+
codeLangBtn.setAttribute('aria-expanded', 'true');
|
|
5469
|
+
el.addEventListener('mousedown', (e) => { if (e.target !== input) e.preventDefault(); });
|
|
5470
|
+
input.addEventListener('input', () => { renderCodeLangItems(m); });
|
|
5471
|
+
input.addEventListener('keydown', (e) => {
|
|
5472
|
+
if (e.isComposing) return;
|
|
5473
|
+
const k = e.key;
|
|
5474
|
+
if (k === 'ArrowDown' || k === 'ArrowUp') {
|
|
5475
|
+
e.preventDefault();
|
|
5476
|
+
e.stopPropagation();
|
|
5477
|
+
if (m.items.length) {
|
|
5478
|
+
m.cursor = (m.cursor + (k === 'ArrowDown' ? 1 : -1) + m.items.length) % m.items.length;
|
|
5479
|
+
markCodeLangCursor(m);
|
|
5480
|
+
}
|
|
5481
|
+
} else if (k === 'Enter') {
|
|
5482
|
+
e.preventDefault();
|
|
5483
|
+
e.stopPropagation();
|
|
5484
|
+
const it = m.items[m.cursor];
|
|
5485
|
+
if (it) pickCodeLang(m, it.id);
|
|
5486
|
+
} else if (k === 'Escape' || k === 'Tab') {
|
|
5487
|
+
e.preventDefault();
|
|
5488
|
+
e.stopPropagation();
|
|
5489
|
+
closeCodeLangMenu({ refocus: true });
|
|
5490
|
+
}
|
|
5491
|
+
});
|
|
5492
|
+
renderCodeLangItems(m);
|
|
5493
|
+
placeCodeLangMenu(m);
|
|
5494
|
+
input.focus({ preventScroll: true });
|
|
5495
|
+
}
|
|
5496
|
+
// `refocus`: give the edit its focus and caret back (a close that keeps
|
|
5497
|
+
// editing); false when the caller is about to move focus itself.
|
|
5498
|
+
function closeCodeLangMenu(o) {
|
|
5499
|
+
const m = codeLangMenu;
|
|
5500
|
+
if (!m) return;
|
|
5501
|
+
codeLangMenu = null;
|
|
5502
|
+
codeLangBtn.setAttribute('aria-expanded', 'false');
|
|
5503
|
+
if (m.el.parentNode) m.el.parentNode.removeChild(m.el);
|
|
5504
|
+
if (o && o.refocus && codeEditIsOpen() && codeEditState === m.s) {
|
|
5505
|
+
m.s.host.focus({ preventScroll: true });
|
|
5506
|
+
if (m.sel) placeCodeEditSelection(m.s, m.sel[0], m.sel[1]);
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
// The text and the new language as one op; the edit opens again at the same
|
|
5510
|
+
// caret (commitWithLang). The menu is closed first, with the caret put
|
|
5511
|
+
// back, so the commit reads the selection the user left.
|
|
5512
|
+
async function pickCodeLang(m, lang) {
|
|
5513
|
+
const s = m.s;
|
|
5514
|
+
closeCodeLangMenu({ refocus: false });
|
|
5515
|
+
if (!codeEditIsOpen() || codeEditState !== s) return;
|
|
5516
|
+
if (m.sel) placeCodeEditSelection(s, m.sel[0], m.sel[1]);
|
|
5517
|
+
await s.commitWithLang(lang);
|
|
5518
|
+
}
|
|
5519
|
+
codeLangBtn.addEventListener('click', async () => {
|
|
5520
|
+
if (codeLangMenu) { closeCodeLangMenu({ refocus: true }); return; }
|
|
5521
|
+
let blk = codeChipsBlock;
|
|
5522
|
+
if (!blk || !blk.isConnected) { hideCodeChips(); return; }
|
|
5523
|
+
// R40: a block that is not being edited opens the edit first. Whatever
|
|
5524
|
+
// else is open (a paragraph typed in a moment ago) is resolved BEFORE, and
|
|
5525
|
+
// its render may replace this block: it is found again by content, only
|
|
5526
|
+
// when it is the one block with that source (R104, as the click router
|
|
5527
|
+
// does), else the gesture is dropped with the notice.
|
|
5528
|
+
// v3.17.0 T9a (R54a): and the router's other two guards, for the same
|
|
5529
|
+
// reason (R102, I-R1): a reconciliation that moved since the click, or
|
|
5530
|
+
// one owed while the block's lines on screen are not its lines in
|
|
5531
|
+
// `lines`, would seed the edit from the wrong lines. Refused instead.
|
|
5532
|
+
if (!(codeEditIsOpen() && codeEditState.blockEl === blk)) {
|
|
5533
|
+
const epochAtClick = reconcileEpoch;
|
|
5534
|
+
const identity = commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blk) : null;
|
|
5535
|
+
if (!(await switchAwayFrom())) return;
|
|
5536
|
+
await settleCommits();
|
|
5537
|
+
blk = refindBlock(blk, identity);
|
|
5538
|
+
if (!blk || reconcileEpoch !== epochAtClick || (reconcileBlocksCommits() && !blockReadsAsDrawn(blk))) {
|
|
5539
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
5540
|
+
return;
|
|
5541
|
+
}
|
|
5542
|
+
if (!(await editCodeInPlace(blk, { offset: 0 }))) return;
|
|
5543
|
+
}
|
|
5544
|
+
const s = liveCodeEdit();
|
|
5545
|
+
if (s && !codeLangMenu) openCodeLangMenu(s);
|
|
5546
|
+
});
|
|
5547
|
+
// A press anywhere else puts the menu away (focus goes where the press
|
|
5548
|
+
// sends it; the click router then resolves the edit as usual).
|
|
5549
|
+
document.addEventListener('mousedown', (e) => {
|
|
5550
|
+
if (!codeLangMenu) return;
|
|
5551
|
+
const t = e.target;
|
|
5552
|
+
if (t && t.closest && t.closest('.ed-code-langmenu, .ed-code-chips')) return;
|
|
5553
|
+
closeCodeLangMenu({ refocus: false });
|
|
5554
|
+
}, true);
|
|
5555
|
+
|
|
4442
5556
|
// ── Phase-2 Task 3: paragraph / heading WYSIWYG editing ────────────────
|
|
4443
5557
|
// A block's rendered content is the .ed-block's single element child
|
|
4444
5558
|
// (see lib/md2doc.js's editMode wrapper: `<div class="ed-block"
|
|
@@ -6291,6 +7405,10 @@
|
|
|
6291
7405
|
// same offset this branch used to compute, indexOf('\n') + 1, with the
|
|
6292
7406
|
// value untouched. So the fix-up is deleted rather than kept as a
|
|
6293
7407
|
// no-op that reads like it is still load-bearing.
|
|
7408
|
+
// v3.17.0 T2 (D2, R21): the skeleton is a fenced block, so it now
|
|
7409
|
+
// opens the in-place edit on its empty body; the raw box is the
|
|
7410
|
+
// fallback for anything that is not one.
|
|
7411
|
+
if (await editCodeInPlace(blockEl, { offset: 0 })) return;
|
|
6294
7412
|
await openRawEditor(blockEl);
|
|
6295
7413
|
return;
|
|
6296
7414
|
}
|
|
@@ -6346,19 +7464,18 @@
|
|
|
6346
7464
|
// preventDefault() names '.ed-insert' as well as '.ed-handle', so the
|
|
6347
7465
|
// burst survives the press and the commit that lands inside
|
|
6348
7466
|
// switchAwayFrom() below can be a rewrite of THIS block's own source —
|
|
6349
|
-
// in which case
|
|
7467
|
+
// in which case refindBlock()'s source fingerprint is guaranteed to
|
|
6350
7468
|
// miss, because WE are the reason the source changed, and the gesture is
|
|
6351
7469
|
// dropped with '文件已更新,請重試這個操作' having done nothing. The
|
|
6352
7470
|
// narrowed re-resolve (startLine + type, no fingerprint) is used ONLY
|
|
6353
7471
|
// when the session that just committed was this block's OWN;
|
|
6354
|
-
//
|
|
7472
|
+
// refindBlock() keeps its fingerprint for everybody else.
|
|
6355
7473
|
const selfSession = ownsOpenSession(blockEl);
|
|
6356
7474
|
const ok = await switchAwayFrom();
|
|
6357
7475
|
if (!ok) return;
|
|
6358
7476
|
let liveBlockEl = blockEl;
|
|
6359
7477
|
if (!document.body.contains(blockEl)) {
|
|
6360
|
-
liveBlockEl =
|
|
6361
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
7478
|
+
liveBlockEl = refindBlock(blockEl, identity, selfSession);
|
|
6362
7479
|
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
6363
7480
|
}
|
|
6364
7481
|
// T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
|
|
@@ -6565,7 +7682,7 @@
|
|
|
6565
7682
|
// startBurst ............................................ include (own
|
|
6566
7683
|
// branch); any other id-less block: skip (R2)
|
|
6567
7684
|
// setBlockSelection / selection walkers / blockRecOf .... skip (null)
|
|
6568
|
-
// captureBlockIdentity /
|
|
7685
|
+
// captureBlockIdentity / refindBlock (a detached one) ... skip (null)
|
|
6569
7686
|
// commitListStructure targets ........................... n/a (li only)
|
|
6570
7687
|
// handleBurstKeydown arrow probe / walkToAdjacentBlockByArrow /
|
|
6571
7688
|
// adjacentFocusableBlock ............................ include: the
|
|
@@ -6679,6 +7796,45 @@
|
|
|
6679
7796
|
return el;
|
|
6680
7797
|
}
|
|
6681
7798
|
|
|
7799
|
+
// v3.17.0 T9a (M-f, L146): a raw box or an in-place code edit left with its
|
|
7800
|
+
// own key (Esc, Ctrl/Cmd+Enter) puts the block's node back, and focus goes
|
|
7801
|
+
// to BODY with the node it was on, where the next key lands nowhere. Once
|
|
7802
|
+
// the leave has landed, the caret goes on to the block right below what was
|
|
7803
|
+
// edited, at its start, or into a transient there when that block takes no
|
|
7804
|
+
// caret or nothing follows. `tail` is how many lines followed the edited
|
|
7805
|
+
// span when the key was pressed: a leave rewrites that span only, so it
|
|
7806
|
+
// still ends `tail` lines before the end of `lines` — which also holds when
|
|
7807
|
+
// the leave took the block away (an untouched insert discarded). Nothing
|
|
7808
|
+
// moves when focus is already somewhere (a click landed meanwhile) or the
|
|
7809
|
+
// screen is not `lines` (a superseded render, a reconciliation owed).
|
|
7810
|
+
// Focus still on `ownEl` (the edited block) counts as nowhere: WebKit keeps
|
|
7811
|
+
// the code edit's host as activeElement once its contenteditable is taken
|
|
7812
|
+
// off when no render replaced it (an unchanged leave; measured).
|
|
7813
|
+
let handingCaretOn = false;
|
|
7814
|
+
function handCaretOnBelow(tail, ownEl) {
|
|
7815
|
+
handingCaretOn = true;
|
|
7816
|
+
try { handCaretOnBelowNow(tail, ownEl); } finally { handingCaretOn = false; }
|
|
7817
|
+
}
|
|
7818
|
+
function handCaretOnBelowNow(tail, ownEl) {
|
|
7819
|
+
const ae = document.activeElement;
|
|
7820
|
+
if (ae && ae !== document.body && ae.isConnected && !(ownEl && ownEl.contains(ae))) return;
|
|
7821
|
+
if (reconcileBlocksCommits() || drawnContent !== lines.join('\n')) return;
|
|
7822
|
+
const editEnd = lines.length - tail;
|
|
7823
|
+
const below = blocks.find((b) => b.startLine > editEnd);
|
|
7824
|
+
const belowEl = below ? blockElById(below.id) : null;
|
|
7825
|
+
const surface = focusableSurfaceOf(belowEl);
|
|
7826
|
+
if (surface) {
|
|
7827
|
+
surface.focus();
|
|
7828
|
+
placeCaretAtSurfaceStart(surface);
|
|
7829
|
+
return;
|
|
7830
|
+
}
|
|
7831
|
+
const t = createTransient(belowEl ? previousBlockEl(belowEl) : lastBlockEl(), { focus: true });
|
|
7832
|
+
// v3.17.0 T10: the transient belongs to the leave. If the leave was an Esc
|
|
7833
|
+
// that discarded text, the next Ctrl+Z takes this transient away and then
|
|
7834
|
+
// brings the text back (burstUndo()).
|
|
7835
|
+
if (t && transient && transient.el === t) transient.afterLeave = true;
|
|
7836
|
+
}
|
|
7837
|
+
|
|
6682
7838
|
// v3.14.0 T4: data-ed-empty on a paragraph surface that holds no text and no
|
|
6683
7839
|
// image, off otherwise. The CSS hint keys off it. Called on focusin and
|
|
6684
7840
|
// input of an armed paragraph surface, by createTransient(), and after the
|
|
@@ -6866,8 +8022,7 @@
|
|
|
6866
8022
|
const ok = await switchAwayFrom();
|
|
6867
8023
|
if (!ok) return null;
|
|
6868
8024
|
if (document.body.contains(blockEl)) return blockEl;
|
|
6869
|
-
const live =
|
|
6870
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
8025
|
+
const live = refindBlock(blockEl, identity, selfSession);
|
|
6871
8026
|
if (!live) showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
6872
8027
|
return live;
|
|
6873
8028
|
}
|
|
@@ -7261,7 +8416,7 @@
|
|
|
7261
8416
|
closeSlashMenu();
|
|
7262
8417
|
if (!blockCatalog || !surface || !surface.isConnected) return;
|
|
7263
8418
|
const el = buildSlashMenuEl();
|
|
7264
|
-
slashMenu = { el, surface, slashOffset, items: [], cursor: 0, query: null };
|
|
8419
|
+
slashMenu = { el, surface, slashOffset, items: [], cursor: 0, query: null, lastMatchLen: 0 };
|
|
7265
8420
|
document.body.appendChild(el);
|
|
7266
8421
|
refreshSlashMenu();
|
|
7267
8422
|
}
|
|
@@ -7288,6 +8443,13 @@
|
|
|
7288
8443
|
// the block below it and take the next click aimed there. A typed "/"
|
|
7289
8444
|
// keeps the empty line (D7): the user asked for the menu.
|
|
7290
8445
|
if (m.slashOffset < 0 && !m.items.length) { closeSlashMenu(); return; }
|
|
8446
|
+
// v3.17.0 T10 (M-a, R42): a typed "/" closes on the third character
|
|
8447
|
+
// past the last query that matched (q.length - lastMatchLen >= 3; the
|
|
8448
|
+
// empty line stays up through the first two). A path such as /usr/bin:
|
|
8449
|
+
// 'u' matched, 's' and 'r' did not, the second "/" is the third and
|
|
8450
|
+
// closes it. The text is plain paragraph text by then; nothing is removed.
|
|
8451
|
+
if (m.items.length) m.lastMatchLen = q.length;
|
|
8452
|
+
else if (q.length - m.lastMatchLen >= 3) { closeSlashMenu(); return; }
|
|
7291
8453
|
renderSlashItems();
|
|
7292
8454
|
}
|
|
7293
8455
|
positionSlashMenu();
|
|
@@ -7487,10 +8649,13 @@
|
|
|
7487
8649
|
const surface = blockContentEl(blockEl);
|
|
7488
8650
|
const typed = surface ? slashSurfaceText(surface) : '';
|
|
7489
8651
|
const identity = captureBlockIdentity(blockEl);
|
|
8652
|
+
// Fix round 1 (C1): gated like every other self-commit fallback; it used
|
|
8653
|
+
// to be taken for any render, its own or not.
|
|
8654
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
7490
8655
|
openImagePicker(async (files) => {
|
|
7491
|
-
let live = blockEl
|
|
7492
|
-
|
|
7493
|
-
if (!live) {
|
|
8656
|
+
let live = refindBlock(blockEl, identity, selfSession);
|
|
8657
|
+
// Fix round 1 (I1, R35): the retry notice, as every re-find refusal.
|
|
8658
|
+
if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
7494
8659
|
const liveSurface = blockContentEl(live);
|
|
7495
8660
|
if (!liveSurface || slashSurfaceText(liveSurface) !== typed) { await insertImages(files, live); return; }
|
|
7496
8661
|
const paths = [];
|
|
@@ -7825,8 +8990,16 @@
|
|
|
7825
8990
|
return;
|
|
7826
8991
|
}
|
|
7827
8992
|
if (match.kind === 'code') {
|
|
7828
|
-
//
|
|
7829
|
-
//
|
|
8993
|
+
// v3.17.0 T2 (R21, R28): the in-place edit, caret at the start of the
|
|
8994
|
+
// body; what was typed meanwhile goes into the body (the language is
|
|
8995
|
+
// the chip's now, not the fence line's).
|
|
8996
|
+
if (await editCodeInPlace(el, { offset: 0 })) {
|
|
8997
|
+
drainHeldKeysIntoCodeEdit();
|
|
8998
|
+
return;
|
|
8999
|
+
}
|
|
9000
|
+
// Not a fenced block after all: the v3.15.0 raw box. The caret right
|
|
9001
|
+
// after the opening fence: a language typed there is the info string,
|
|
9002
|
+
// and Enter moves on to the body (R22).
|
|
7830
9003
|
const fence = converted[0];
|
|
7831
9004
|
await openRawEditor(el, undefined, { value: converted.join('\n'), caret: fence.length, enterToBody: true });
|
|
7832
9005
|
drainHeldKeysIntoRawBox(true);
|
|
@@ -9091,7 +10264,10 @@
|
|
|
9091
10264
|
// `data-block-id` is null (blockRecOf() returns null for both). Either
|
|
9092
10265
|
// way there is no record to read a startLine off.
|
|
9093
10266
|
// See restoreAfterStructuralOp().
|
|
9094
|
-
|
|
10267
|
+
// `opts.goneIsDone` (T9a fix round 2, R64; ⠿ 刪除 only): when the gesture's
|
|
10268
|
+
// own commit removed the block, the gesture is already done — null with no
|
|
10269
|
+
// notice. Every other caller refuses with the notice.
|
|
10270
|
+
async function resolveGutterOperands(blockEl, anchor, opts) {
|
|
9095
10271
|
// R94: every ⠿ action (and the toolbar's conversions) waits for a commit
|
|
9096
10272
|
// in flight before it takes the block's identity.
|
|
9097
10273
|
blockEl = await settledGestureBlock(blockEl);
|
|
@@ -9099,7 +10275,7 @@
|
|
|
9099
10275
|
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
9100
10276
|
// alive across the ⠿ press, so the commit that lands inside
|
|
9101
10277
|
// switchAwayFrom() below can be a rewrite of the very block the gesture
|
|
9102
|
-
// names — in which case
|
|
10278
|
+
// names — in which case refindBlock()'s SOURCE fingerprint is
|
|
9103
10279
|
// guaranteed to miss, because WE are the reason the source changed. That
|
|
9104
10280
|
// is not a dropped gesture; startLine + type still name the block, and the
|
|
9105
10281
|
// fingerprint's job (proving an UNRELATED commit did not move somebody
|
|
@@ -9110,9 +10286,13 @@
|
|
|
9110
10286
|
if (!ok) return null;
|
|
9111
10287
|
let liveBlockEl = blockEl;
|
|
9112
10288
|
if (!document.body.contains(blockEl)) {
|
|
9113
|
-
liveBlockEl =
|
|
9114
|
-
|
|
9115
|
-
|
|
10289
|
+
liveBlockEl = refindBlock(blockEl, identity, selfSession);
|
|
10290
|
+
if (!liveBlockEl) {
|
|
10291
|
+
if (!(opts && opts.goneIsDone && selfSession && selfCommitRemovedBlock(identity))) {
|
|
10292
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
10293
|
+
}
|
|
10294
|
+
return null;
|
|
10295
|
+
}
|
|
9116
10296
|
}
|
|
9117
10297
|
// A block that owns no source line has an INVERTED range
|
|
9118
10298
|
// (endLine === startLine - 1) and every commit helper handed one does
|
|
@@ -9370,7 +10550,12 @@
|
|
|
9370
10550
|
const target = blocks.find((b) => b.startLine === focusLine);
|
|
9371
10551
|
const blockEl = target &&
|
|
9372
10552
|
document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
9373
|
-
|
|
10553
|
+
// v3.17.0 T2 (R28): a block converted to 程式碼 opens the in-place
|
|
10554
|
+
// edit (the same answer as a click on it); a quote keeps the raw box.
|
|
10555
|
+
if (blockEl) {
|
|
10556
|
+
editCodeInPlace(blockEl, { offset: 0 })
|
|
10557
|
+
.then((opened) => (opened ? null : openRawEditor(blockEl))).catch(() => {});
|
|
10558
|
+
}
|
|
9374
10559
|
}
|
|
9375
10560
|
return;
|
|
9376
10561
|
}
|
|
@@ -9623,13 +10808,30 @@
|
|
|
9623
10808
|
|
|
9624
10809
|
// The narrowed re-resolve for the case above: startLine and type must still
|
|
9625
10810
|
// match, but the source is allowed to differ because our own switchAwayFrom()
|
|
9626
|
-
// just rewrote it.
|
|
9627
|
-
//
|
|
9628
|
-
//
|
|
9629
|
-
// block the user never pointed at.
|
|
10811
|
+
// just rewrote it. Reached only through refindBlock(), and only when its
|
|
10812
|
+
// caller passes `selfSession` (ownsOpenSession() read before the commit);
|
|
10813
|
+
// every other re-find keeps the content fingerprint, because skipping it is
|
|
10814
|
+
// the shape that lets a call site act on a block the user never pointed at.
|
|
10815
|
+
//
|
|
10816
|
+
// T9a fix round 2 (R64): null when that commit REMOVED the block (R95's
|
|
10817
|
+
// removal of an emptied paragraph). Its start line then names the block
|
|
10818
|
+
// below, of the same type more often than not — measured: empty `Alpha.`,
|
|
10819
|
+
// ⠿ 刪除 on it, and `Beta.` below was deleted too.
|
|
10820
|
+
// Whether the block's own commit took all of its lines away. That commit
|
|
10821
|
+
// rewrote the block's span and nothing else, so the lines below it are the
|
|
10822
|
+
// `tail` lines at the end of the file: the span now runs from its start line
|
|
10823
|
+
// to `lines.length - tail`, and an empty span is a removed block. Read from
|
|
10824
|
+
// line counts only — a type or a source at the start line cannot tell the
|
|
10825
|
+
// block from the one that moved up into its place. An identity taken before
|
|
10826
|
+
// `tail` existed (none today) never reads as removed.
|
|
10827
|
+
function selfCommitRemovedBlock(identity) {
|
|
10828
|
+
return !!identity && identity.tail !== undefined &&
|
|
10829
|
+
lines.length - identity.tail < identityStartLineNow(identity);
|
|
10830
|
+
}
|
|
9630
10831
|
function reresolveBlockElAfterSelfCommit(identity) {
|
|
9631
10832
|
if (!identity) return null;
|
|
9632
|
-
if (identity.reconcileEpoch !== reconcileEpoch) return null; // R84
|
|
10833
|
+
if (identity.reconcileEpoch !== reconcileEpoch) return null; // R84 (refindBlock()'s uniqueBlockElBySource() asks the same)
|
|
10834
|
+
if (selfCommitRemovedBlock(identity)) return null;
|
|
9633
10835
|
const startLine = identityStartLineNow(identity);
|
|
9634
10836
|
const at = blocks.find((b) => b.startLine === startLine);
|
|
9635
10837
|
if (!at || at.type !== identity.type) return null;
|
|
@@ -10023,7 +11225,7 @@
|
|
|
10023
11225
|
// re-resolve is highest here: an id shift used to make this delete a
|
|
10024
11226
|
// DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
|
|
10025
11227
|
// block that survived.
|
|
10026
|
-
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
11228
|
+
const operands = await resolveGutterOperands(blockEl, anchor, { goneIsDone: true });
|
|
10027
11229
|
if (!operands) return;
|
|
10028
11230
|
const els = operands.els;
|
|
10029
11231
|
const recs = operands.recs;
|
|
@@ -11043,12 +12245,17 @@
|
|
|
11043
12245
|
function startBurst(editEl) {
|
|
11044
12246
|
// v3.15.0 B5 fix round 4 (R86 (2)): never on a screen that is not `lines`.
|
|
11045
12247
|
if (burstsLocked()) return;
|
|
11046
|
-
|
|
11047
|
-
//
|
|
11048
|
-
//
|
|
11049
|
-
|
|
11050
|
-
|
|
11051
|
-
|
|
12248
|
+
// v3.17.0 T10: the caret a leave by key hands on (handCaretOnBelow()) is
|
|
12249
|
+
// not a new session of the user's own, so an Esc's discarded text stays
|
|
12250
|
+
// recoverable with the next Ctrl+Z, as it was when that focus went to BODY.
|
|
12251
|
+
if (!handingCaretOn) {
|
|
12252
|
+
dropDiscardedBurst();
|
|
12253
|
+
// v3.4.0 (Task 7 item 5): opening ANY new session — burst or raw —
|
|
12254
|
+
// invalidates a still-parked raw-edit discard from a different block; see
|
|
12255
|
+
// discardedRawEdit's own comment for why the two stashes must stay
|
|
12256
|
+
// mutually exclusive.
|
|
12257
|
+
dropDiscardedRawEdit();
|
|
12258
|
+
}
|
|
11052
12259
|
const blockEl = editEl.closest('.ed-block');
|
|
11053
12260
|
if (!blockEl) return;
|
|
11054
12261
|
// v3.14.0 T1: a transient paragraph has no block record and no line. Its
|
|
@@ -11353,7 +12560,11 @@
|
|
|
11353
12560
|
// browser leaves in an emptied contenteditable wrote a literal `<br>` line
|
|
11354
12561
|
// (measured: Backspace ×6 on `Omega.`, click away → `<br>` on disk). A
|
|
11355
12562
|
// heading keeps its `#` run (R45), so this is paragraphs only.
|
|
11356
|
-
|
|
12563
|
+
// v3.17.0 T10 (N6): the burst's own surface decides. transientSurfaceEmpty()
|
|
12564
|
+
// answers true when it finds no surface, which would remove a paragraph
|
|
12565
|
+
// this burst never saw empty.
|
|
12566
|
+
if (burst.blockType === 'paragraph' && burst.editEl &&
|
|
12567
|
+
burst.editEl.textContent === '' && !burst.editEl.querySelector('img')) {
|
|
11357
12568
|
return await removeEmptiedParagraph(burst);
|
|
11358
12569
|
}
|
|
11359
12570
|
// Task 5: a table burst serializes through table-md.js's serializeTable()
|
|
@@ -11457,7 +12668,13 @@
|
|
|
11457
12668
|
}
|
|
11458
12669
|
if (focusInside && ok !== RENDER_SUPERSEDED) {
|
|
11459
12670
|
const anchor = preceding ? blockElAtLine(preceding.startLine) : null;
|
|
11460
|
-
if (!preceding || anchor)
|
|
12671
|
+
if (!preceding || anchor) {
|
|
12672
|
+
const t = createTransient(anchor ? subtreeEndOf(anchor) : null, { focus: true });
|
|
12673
|
+
// v3.17.0 T10 (N4): this transient is part of the removal — while it
|
|
12674
|
+
// is still empty and nothing else was committed since, one Ctrl+Z
|
|
12675
|
+
// takes it away AND brings the paragraph back (burstUndo()).
|
|
12676
|
+
if (t && transient && transient.el === t) transient.afterEmptied = lines;
|
|
12677
|
+
}
|
|
11461
12678
|
}
|
|
11462
12679
|
return true;
|
|
11463
12680
|
}
|
|
@@ -11769,7 +12986,7 @@
|
|
|
11769
12986
|
// selection instead of opening the menu, and a drag inside the raw
|
|
11770
12987
|
// textarea would fight its own text selection.
|
|
11771
12988
|
const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-slash-menu, ' +
|
|
11772
|
-
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-conflict, .ed-raw';
|
|
12989
|
+
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw';
|
|
11773
12990
|
function isSelGestureChrome(target) {
|
|
11774
12991
|
return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
|
|
11775
12992
|
}
|
|
@@ -11973,6 +13190,13 @@
|
|
|
11973
13190
|
// fingerprint fails, the caller DROPS the gesture and says so. Never
|
|
11974
13191
|
// guessed: completing every gesture is worth less than never acting on a
|
|
11975
13192
|
// block the user did not point at.
|
|
13193
|
+
//
|
|
13194
|
+
// v3.17.0 T9a (M-R5, R35): the start line turned out to be no proof either
|
|
13195
|
+
// (R104: R95's removal of an emptied paragraph shifts lines unlogged, and an
|
|
13196
|
+
// identical block then sits on the old line). A node a render replaced is
|
|
13197
|
+
// now found again only by refindBlock() below — the one block of its type
|
|
13198
|
+
// with that source, or nothing. The start line is still the handle for
|
|
13199
|
+
// reresolveBlockElAfterSelfCommit(), whose source legitimately changed.
|
|
11976
13200
|
function blockSourceOf(block) {
|
|
11977
13201
|
return lines.slice(block.startLine - 1, block.endLine).join('\n');
|
|
11978
13202
|
}
|
|
@@ -11982,8 +13206,10 @@
|
|
|
11982
13206
|
if (raw === null) return null;
|
|
11983
13207
|
const b = blocks.find((x) => x.id === Number(raw));
|
|
11984
13208
|
if (!b) return null;
|
|
13209
|
+
// `tail` (T9a fix round 2, R64): the lines below the block, for
|
|
13210
|
+
// selfCommitRemovedBlock().
|
|
11985
13211
|
return { startLine: b.startLine, type: b.type, source: blockSourceOf(b),
|
|
11986
|
-
transientSeq: transientCommits.seq, reconcileEpoch };
|
|
13212
|
+
tail: lines.length - b.endLine, transientSeq: transientCommits.seq, reconcileEpoch };
|
|
11987
13213
|
}
|
|
11988
13214
|
// v3.14.0 T1 fix round 1 (I2): where `identity`'s block starts NOW. A
|
|
11989
13215
|
// transient's commit is a pure insertion — no existing line is rewritten —
|
|
@@ -11993,7 +13219,9 @@
|
|
|
11993
13219
|
// before: type into a transient, then + / ⠿ on any block below it — the
|
|
11994
13220
|
// gesture's own switchAwayFrom() committed the transient, the target's
|
|
11995
13221
|
// start line moved by ≥ 2, and the gesture was dropped with
|
|
11996
|
-
// DROPPED_GESTURE_MESSAGE.
|
|
13222
|
+
// DROPPED_GESTURE_MESSAGE. Since v3.17.0 T9a only
|
|
13223
|
+
// reresolveBlockElAfterSelfCommit() reads it; the stale re-find
|
|
13224
|
+
// (refindBlock()) reads no start line.
|
|
11997
13225
|
function identityStartLineNow(identity) {
|
|
11998
13226
|
let line = identity.startLine;
|
|
11999
13227
|
const since = identity.transientSeq === undefined ? transientCommits.seq : identity.transientSeq;
|
|
@@ -12002,16 +13230,6 @@
|
|
|
12002
13230
|
});
|
|
12003
13231
|
return line;
|
|
12004
13232
|
}
|
|
12005
|
-
function reresolveBlockEl(identity) {
|
|
12006
|
-
if (!identity) return null;
|
|
12007
|
-
// v3.15.0 B5 fix round 3 (R84): taken before a reconciliation redraw, it
|
|
12008
|
-
// names a line of the stale screen; the gesture is refused instead.
|
|
12009
|
-
if (identity.reconcileEpoch !== reconcileEpoch) return null;
|
|
12010
|
-
const startLine = identityStartLineNow(identity);
|
|
12011
|
-
const at = blocks.find((b) => b.startLine === startLine);
|
|
12012
|
-
if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
|
|
12013
|
-
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
12014
|
-
}
|
|
12015
13233
|
|
|
12016
13234
|
// What a caller says when it refuses to act rather than act on the wrong
|
|
12017
13235
|
// block. Dismiss-only, same shape as refuseStructuralListEdit()'s banner —
|
|
@@ -12087,7 +13305,8 @@
|
|
|
12087
13305
|
if (!blockEl.getAttribute('data-block-id')) return false;
|
|
12088
13306
|
const editEl = blockContentEl(blockEl);
|
|
12089
13307
|
if (editEl && (editEl.classList.contains('ed-wys-armed') || editEl.classList.contains('ed-wys-table'))) return false;
|
|
12090
|
-
|
|
13308
|
+
// v3.17.0 T2 (R24): nor while it is being edited in place.
|
|
13309
|
+
return !blockEl.querySelector('.ed-raw, [data-ed-code-edit], .ed-diagram-panel');
|
|
12091
13310
|
}
|
|
12092
13311
|
// R102 (I-R1): while a reconciliation is owed, `blocks` describes what is
|
|
12093
13312
|
// drawn (`drawnContent`), not `lines`. True when `blockEl`'s lines read the
|
|
@@ -12110,6 +13329,53 @@
|
|
|
12110
13329
|
const hits = blocks.filter((b) => b.type === identity.type && blockSourceOf(b) === identity.source);
|
|
12111
13330
|
return hits.length === 1 ? blockElById(hits[0].id) : null;
|
|
12112
13331
|
}
|
|
13332
|
+
// v3.17.0 T9a (M-R5, R35): the element a gesture names once what it awaited
|
|
13333
|
+
// has landed — itself while a render kept it on screen, otherwise the one
|
|
13334
|
+
// block found again by content (uniqueBlockElBySource()), or null, and the
|
|
13335
|
+
// caller refuses with the notice and writes nothing. The cost, accepted: a
|
|
13336
|
+
// gesture on a block with an identical twin, after a render replaced it,
|
|
13337
|
+
// needs a second try.
|
|
13338
|
+
//
|
|
13339
|
+
// `selfSession` (fix round 1, C1): the commit that replaced the node was
|
|
13340
|
+
// this block's OWN open session (ownsOpenSession(), read before the
|
|
13341
|
+
// commit). That commit rewrote the block, so `identity.source` can only
|
|
13342
|
+
// belong to ANOTHER block now, and a content match would act on its
|
|
13343
|
+
// identical twin (measured: type into the first of two `Twin.`, ⠿ 刪除 on
|
|
13344
|
+
// it, the second was deleted). The block is then the one at its start line
|
|
13345
|
+
// (reresolveBlockElAfterSelfCommit()), never a content match.
|
|
13346
|
+
function refindBlock(blockEl, identity, selfSession) {
|
|
13347
|
+
if (blockEl && blockEl.isConnected) return blockEl;
|
|
13348
|
+
return selfSession ? reresolveBlockElAfterSelfCommit(identity) : uniqueBlockElBySource(identity);
|
|
13349
|
+
}
|
|
13350
|
+
// v3.17.0 T10 (N2): a checkbox click is not a caret gesture, but the press
|
|
13351
|
+
// does not move focus either (its mousedown is prevented), so a caret that
|
|
13352
|
+
// sat in another paragraph, heading or item stays where the user left it.
|
|
13353
|
+
// Before, the toggle's resolution blurred it and the next keys went to
|
|
13354
|
+
// BODY. Not for a transient, a table cell, or an item of the toggled run
|
|
13355
|
+
// (that run is re-rendered whole).
|
|
13356
|
+
function captureCaretBeforeCheckbox(run) {
|
|
13357
|
+
const act = document.activeElement;
|
|
13358
|
+
const surface = act && act.closest ? act.closest('.ed-wys-armed') : null;
|
|
13359
|
+
if (!surface || surface.closest('[data-ed-transient]')) return null;
|
|
13360
|
+
const blockEl = surface.closest('.ed-block');
|
|
13361
|
+
if (!blockEl || run.indexOf(blockEl) !== -1) return null;
|
|
13362
|
+
const snap = selectionSnapshotIn(surface);
|
|
13363
|
+
if (!snap || snap.focus === null) return null;
|
|
13364
|
+
return { blockEl, identity: captureBlockIdentity(blockEl), selfSession: ownsOpenSession(blockEl),
|
|
13365
|
+
anchor: snap.anchor, focus: snap.focus };
|
|
13366
|
+
}
|
|
13367
|
+
// Puts it back after the toggle's render, in the same block found again —
|
|
13368
|
+
// unless focus went somewhere meanwhile (a click, a key), which wins.
|
|
13369
|
+
function restoreCaretAfterCheckbox(keep) {
|
|
13370
|
+
if (!keep) return;
|
|
13371
|
+
const act = document.activeElement;
|
|
13372
|
+
if (act && act !== document.body && act !== document.documentElement) return;
|
|
13373
|
+
const live = refindBlock(keep.blockEl, keep.identity, keep.selfSession);
|
|
13374
|
+
const surface = live ? blockContentEl(live) : null;
|
|
13375
|
+
if (!surface || !surface.isConnected || !surface.classList.contains('ed-wys-armed')) return;
|
|
13376
|
+
surface.focus({ preventScroll: true });
|
|
13377
|
+
restoreSelection(surface, keep.anchor, keep.focus);
|
|
13378
|
+
}
|
|
12113
13379
|
// R93: the item a settled checkbox click names (see the click handler).
|
|
12114
13380
|
function checkItemAfterSettle(li, snap) {
|
|
12115
13381
|
if (li.isConnected) return li;
|
|
@@ -12812,6 +14078,12 @@
|
|
|
12812
14078
|
// below its divider is part of that gesture, so the same press also
|
|
12813
14079
|
// takes the conversion back (undo() puts the caret after the literal).
|
|
12814
14080
|
if (t && t.el === burst.blockEl && t.afterShortcut) { undo(); return; }
|
|
14081
|
+
// v3.17.0 T10 (N4): the same for the transient an emptied paragraph's
|
|
14082
|
+
// removal left, while `lines` is still what that removal wrote.
|
|
14083
|
+
if (t && t.el === burst.blockEl && t.afterEmptied === lines) { undo(); return; }
|
|
14084
|
+
// v3.17.0 T10: and the one a leave by key handed the caret to, while an
|
|
14085
|
+
// Esc's discarded text waits to be restored (restoreDiscardedRawEdit()).
|
|
14086
|
+
if (t && t.el === burst.blockEl && t.afterLeave && (discardedRawEdit || discardedBurst)) { undo(); return; }
|
|
12815
14087
|
if (t && t.anchorCaret && t.anchorCaret.surface && t.anchorCaret.surface.isConnected) {
|
|
12816
14088
|
t.anchorCaret.surface.focus();
|
|
12817
14089
|
restoreSelection(t.anchorCaret.surface, t.anchorCaret.offset, t.anchorCaret.offset);
|
|
@@ -14453,13 +15725,14 @@
|
|
|
14453
15725
|
// Resolving first and re-resolving afterwards is also the shape
|
|
14454
15726
|
// ensureTableBurstOpen() above already uses against the same hazard.
|
|
14455
15727
|
//
|
|
14456
|
-
// captureBlockIdentity() /
|
|
15728
|
+
// captureBlockIdentity() / refindBlock() is the handle, for the reason
|
|
14457
15729
|
// their own comment writes down: blockmap.js renumbers ids from zero on
|
|
14458
15730
|
// every render, so a captured id can name the destination's NEIGHBOUR after
|
|
14459
|
-
// a commit that changed the block count, and
|
|
14460
|
-
// own source lines is what proves the block
|
|
14461
|
-
// one the press was aimed at. When it cannot be
|
|
14462
|
-
// the conservative half of the trade the gutter
|
|
15731
|
+
// a commit that changed the block count, and (since v3.17.0 T9a) being the
|
|
15732
|
+
// one block of its type with its own source lines is what proves the block
|
|
15733
|
+
// found afterwards is the one the press was aimed at. When it cannot be
|
|
15734
|
+
// proven nothing is focused — the conservative half of the trade the gutter
|
|
15735
|
+
// gestures already take.
|
|
14463
15736
|
//
|
|
14464
15737
|
// Nothing is focused when no such block exists either, and the caller's
|
|
14465
15738
|
// preventDefault() still stands over that press. Handing it back to the
|
|
@@ -14495,8 +15768,11 @@
|
|
|
14495
15768
|
const identity = captureBlockIdentity(destBlockEl);
|
|
14496
15769
|
const ok = await switchAwayFrom();
|
|
14497
15770
|
if (!ok) return;
|
|
14498
|
-
|
|
14499
|
-
|
|
15771
|
+
// v3.17.0 T9a (M-R5): a destination a render replaced and that cannot be
|
|
15772
|
+
// found again (R104) is refused with the notice, never walked past in
|
|
15773
|
+
// silence.
|
|
15774
|
+
const liveBlockEl = refindBlock(destBlockEl, identity);
|
|
15775
|
+
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
14500
15776
|
const surface = focusableSurfaceOf(liveBlockEl);
|
|
14501
15777
|
if (!surface) return;
|
|
14502
15778
|
surface.focus();
|
|
@@ -15173,7 +16449,7 @@
|
|
|
15173
16449
|
const srcRec = blocks.find((b) => b.startLine === srcStartLine);
|
|
15174
16450
|
const liveSrc = srcRec
|
|
15175
16451
|
? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
|
|
15176
|
-
dest = (liveSrc && nextBlock(liveSrc)) ||
|
|
16452
|
+
dest = (liveSrc && nextBlock(liveSrc)) || refindBlock(dest, destIdentity);
|
|
15177
16453
|
// A transient destination is gone with the render that committed the
|
|
15178
16454
|
// source; there is nothing to re-resolve and nothing was dropped.
|
|
15179
16455
|
if (!dest) { if (!destWasTransient) showError(DROPPED_GESTURE_MESSAGE); return; }
|
|
@@ -17970,9 +19246,9 @@
|
|
|
17970
19246
|
// The DESTINATION needs the same protection and gets it one step earlier:
|
|
17971
19247
|
// its identity is captured BEFORE resolveGutterOperands(), whose
|
|
17972
19248
|
// switchAwayFrom() may commit some other block's dirty burst and re-render.
|
|
17973
|
-
//
|
|
17974
|
-
//
|
|
17975
|
-
// does; when it cannot, the gesture is DROPPED with a banner rather than
|
|
19249
|
+
// refindBlock() then proves the block found afterwards really is the
|
|
19250
|
+
// same one (the one block with its source), exactly as the source's own
|
|
19251
|
+
// re-resolve does; when it cannot, the gesture is DROPPED with a banner rather than
|
|
17976
19252
|
// completed against a block the user did not point at.
|
|
17977
19253
|
async function performBlockDrop(st) {
|
|
17978
19254
|
const target = st.dropTarget;
|
|
@@ -18013,13 +19289,13 @@
|
|
|
18013
19289
|
// '文件已更新,請重試這個操作' with the edit committed and the move not.
|
|
18014
19290
|
//
|
|
18015
19291
|
// It is an ASYMMETRY, not a race, and it could not be anything else:
|
|
18016
|
-
//
|
|
19292
|
+
// refindBlock()'s fingerprint is the block's SOURCE, and the commit
|
|
18017
19293
|
// that changed that source is this drop's OWN resolveGutterOperands() →
|
|
18018
19294
|
// switchAwayFrom(). The fingerprint is therefore GUARANTEED to miss. The
|
|
18019
19295
|
// source side has had reresolveBlockElAfterSelfCommit() for exactly this
|
|
18020
19296
|
// since S2; this is its destination-side counterpart, gated the same way
|
|
18021
19297
|
// — on ownsOpenSession() read BEFORE the commit, because `currentBurst` is
|
|
18022
|
-
// gone afterwards — so
|
|
19298
|
+
// gone afterwards — so refindBlock() keeps its fingerprint for
|
|
18023
19299
|
// everybody else. Deliberately NOT a widening of that function: S2's own
|
|
18024
19300
|
// note says the narrowed variant exists precisely so no other caller loses
|
|
18025
19301
|
// the fingerprint.
|
|
@@ -18031,8 +19307,7 @@
|
|
|
18031
19307
|
if (!operands) return;
|
|
18032
19308
|
let liveDestEl = destEl;
|
|
18033
19309
|
if (destEl && !document.body.contains(destEl)) {
|
|
18034
|
-
liveDestEl =
|
|
18035
|
-
(destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
|
|
19310
|
+
liveDestEl = refindBlock(destEl, destIdentity, destSelfSession);
|
|
18036
19311
|
if (!liveDestEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
18037
19312
|
}
|
|
18038
19313
|
// ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
|
|
@@ -19563,11 +20838,33 @@
|
|
|
19563
20838
|
const w = el.getBoundingClientRect().width;
|
|
19564
20839
|
el.style.top = Math.max(margin, top) + 'px';
|
|
19565
20840
|
el.style.left = Math.max(margin, Math.min(left, window.innerWidth - w - margin)) + 'px';
|
|
19566
|
-
|
|
20841
|
+
// v3.17.0 T10 (M-c): where the field sits relative to the selection, so a
|
|
20842
|
+
// page scroll can carry it along (followLinkField).
|
|
20843
|
+
const sr = range.getBoundingClientRect();
|
|
20844
|
+
const fr = el.getBoundingClientRect();
|
|
20845
|
+
linkFieldState = { el, input, root, range, existing: opts.existing || null,
|
|
20846
|
+
dy: fr.top - sr.bottom, dx: fr.left - sr.left };
|
|
19567
20847
|
input.focus({ preventScroll: true });
|
|
19568
20848
|
if (input.value) input.select();
|
|
19569
20849
|
}
|
|
19570
20850
|
|
|
20851
|
+
// v3.17.0 T10 (M-c, was R24): a scroll moves the field with its selection
|
|
20852
|
+
// and keeps its typed text; it closes (back to the toolbar) only when the
|
|
20853
|
+
// selection has left the viewport.
|
|
20854
|
+
function followLinkField() {
|
|
20855
|
+
const f = linkFieldState;
|
|
20856
|
+
if (!f) return;
|
|
20857
|
+
if (!f.root.isConnected) { closeLinkField({ restore: false }); return; }
|
|
20858
|
+
const r = f.range.getBoundingClientRect();
|
|
20859
|
+
if (r.bottom <= toolbarTopInset() || r.top >= window.innerHeight ||
|
|
20860
|
+
r.right <= 0 || r.left >= window.innerWidth) {
|
|
20861
|
+
closeLinkField({ restore: true });
|
|
20862
|
+
return;
|
|
20863
|
+
}
|
|
20864
|
+
f.el.style.top = Math.round(r.bottom + f.dy) + 'px';
|
|
20865
|
+
f.el.style.left = Math.round(r.left + f.dx) + 'px';
|
|
20866
|
+
}
|
|
20867
|
+
|
|
19571
20868
|
// Takes the field down. `restore` puts the focus and the selection back on
|
|
19572
20869
|
// the surface and shows the selection toolbar again (Esc, a scroll).
|
|
19573
20870
|
function closeLinkField(opts) {
|
|
@@ -19947,7 +21244,9 @@
|
|
|
19947
21244
|
// three classes and the checkbox.
|
|
19948
21245
|
let checkPress = null; // { el, epoch, snap }: the last press on a task checkbox (R89, R93)
|
|
19949
21246
|
let degradedPress = null; // { el, epoch, identity }: the last press on a degraded block (R102)
|
|
21247
|
+
let degradedReleaseOpened = false; // T9b (R50): the last press's release already opened its block
|
|
19950
21248
|
document.addEventListener('mousedown', (e) => {
|
|
21249
|
+
degradedReleaseOpened = false;
|
|
19951
21250
|
// T21 item 2: pointing at something is the other way (besides pressing a
|
|
19952
21251
|
// key that is not Tab — see handleBurstKeydown()) of saying the walk is
|
|
19953
21252
|
// over. A click that lands on the SAME surface the walk arrived at does
|
|
@@ -19992,6 +21291,8 @@
|
|
|
19992
21291
|
// below and clear the set it just built; one released on the page
|
|
19993
21292
|
// margin would hit the outside-a-block switchAwayFrom() instead.
|
|
19994
21293
|
if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
|
|
21294
|
+
// v3.17.0 T9b (R50): the release of this press already answered it.
|
|
21295
|
+
if (degradedReleaseOpened) { degradedReleaseOpened = false; return; }
|
|
19995
21296
|
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); return; }
|
|
19996
21297
|
// showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
|
|
19997
21298
|
// .ed-block — so without this guard a click on the banner's own
|
|
@@ -20014,6 +21315,14 @@
|
|
|
20014
21315
|
// here (closest() still answers on the detached subtree); without this
|
|
20015
21316
|
// a mouse pick also ran the "outside any block" switchAwayFrom() below.
|
|
20016
21317
|
if (e.target.closest('.ed-slash-menu')) return;
|
|
21318
|
+
// v3.17.0 T3: the code chips and the language menu handle themselves.
|
|
21319
|
+
if (e.target.closest('.ed-code-chips, .ed-code-langmenu')) return;
|
|
21320
|
+
if (e.target.closest('.ed-imgtb')) return; // v3.17.0 T7: so does the image toolbar
|
|
21321
|
+
// v3.17.0 T5: so does the theme toggle — switching the theme is not
|
|
21322
|
+
// leaving what is being edited (the diagram panel's preview follows the
|
|
21323
|
+
// switch while it stays open). Its press keeps focus where it is too
|
|
21324
|
+
// (the mousedown listener beside the diagram panel).
|
|
21325
|
+
if (e.target.closest('.md2doc-theme-toggle')) return;
|
|
20017
21326
|
// Task 6 (grip handles): a plain click's own `pointerdown`/`pointerup`
|
|
20018
21327
|
// pair above already opened the menu — unlike the bubbles/menu
|
|
20019
21328
|
// buttons, the grips have no `click` listener of their own to
|
|
@@ -20028,7 +21337,7 @@
|
|
|
20028
21337
|
// "clicked outside any block" and fire switchAwayFrom() on the document
|
|
20029
21338
|
// behind the modal — and wrong about being a list at all.
|
|
20030
21339
|
if (waveEditorIsModal()) return;
|
|
20031
|
-
if (e.target.closest('.ed-wave-edit-btn')) return;
|
|
21340
|
+
if (e.target.closest('.ed-wave-edit-btn, .ed-diagram-edit-btn')) return;
|
|
20032
21341
|
// Task 9: task-list checkbox toggle. The .ed-li-check span is
|
|
20033
21342
|
// non-focusable chrome — no per-node listener, routed here by
|
|
20034
21343
|
// delegation. Gate BEFORE mutation (same contract as the structural
|
|
@@ -20089,6 +21398,9 @@
|
|
|
20089
21398
|
checkPress = null;
|
|
20090
21399
|
const pressEpoch = press ? press.epoch : reconcileEpoch;
|
|
20091
21400
|
const snap = press ? press.snap : checkItemSnapshot(li);
|
|
21401
|
+
// v3.17.0 T10 (N2): the caret the press found, read before anything
|
|
21402
|
+
// resolves, so it can be put back once the toggle has landed.
|
|
21403
|
+
const keepCaret = captureCaretBeforeCheckbox(run);
|
|
20092
21404
|
const ok = await switchAwayFrom();
|
|
20093
21405
|
if (!ok) return;
|
|
20094
21406
|
await settleCommits();
|
|
@@ -20118,8 +21430,9 @@
|
|
|
20118
21430
|
// splitSourceMarkers() strips the old one off the replayed line), and
|
|
20119
21431
|
// everything after that marker — this item's own continuation lines
|
|
20120
21432
|
// included — comes back byte-for-byte.
|
|
20121
|
-
await commitListStructure(targetRun, null, false,
|
|
21433
|
+
const toggled = await commitListStructure(targetRun, null, false,
|
|
20122
21434
|
{ carryOver: bystanderCarryOver(targetRun, null) });
|
|
21435
|
+
if (toggled === true) restoreCaretAfterCheckbox(keepCaret);
|
|
20123
21436
|
return;
|
|
20124
21437
|
}
|
|
20125
21438
|
// ⠿ handle: toggles its menu for the block it belongs to. ⠿ menu: its
|
|
@@ -20169,9 +21482,7 @@
|
|
|
20169
21482
|
// tidier test and is NOT usable: for a synthesized event Chromium still
|
|
20170
21483
|
// derives offsetX from clientX 0, so it comes back as minus the block's
|
|
20171
21484
|
// whole left offset and every such click reads as a gutter click.
|
|
20172
|
-
if (blockEl && e.target
|
|
20173
|
-
if (e.clientX < blockEl.getBoundingClientRect().left) blockEl = null;
|
|
20174
|
-
}
|
|
21485
|
+
if (blockEl && pressInGutterBand(e.target, blockEl, e)) blockEl = null;
|
|
20175
21486
|
if (!blockEl) { await switchAwayFrom(); return; } // clicked outside any block
|
|
20176
21487
|
|
|
20177
21488
|
// Task 5: a table block is now armed exactly like paragraph/heading/
|
|
@@ -20186,8 +21497,10 @@
|
|
|
20186
21497
|
// (Global Constraint) — no bar, no extra step, same as any other
|
|
20187
21498
|
// degraded block.
|
|
20188
21499
|
const editEl = blockContentEl(blockEl);
|
|
21500
|
+
// v3.17.0 T2 (R24): a click inside an open in-place edit is a caret
|
|
21501
|
+
// move inside it, never a commit-and-reopen.
|
|
20189
21502
|
if ((editEl && (editEl.classList.contains('ed-wys-armed') || editEl.classList.contains('ed-wys-table'))) ||
|
|
20190
|
-
blockEl.querySelector('.ed-raw')) return;
|
|
21503
|
+
blockEl.querySelector('.ed-raw, [data-ed-code-edit], .ed-diagram-panel')) return;
|
|
20191
21504
|
// Degraded block, not yet open: click swaps in the raw textarea
|
|
20192
21505
|
// immediately — no bar, no menu step. Still resolve whatever else
|
|
20193
21506
|
// might be open first, same precondition every other open path uses.
|
|
@@ -20215,26 +21528,87 @@
|
|
|
20215
21528
|
// right, and its commit runs the owed redraw first (R86).
|
|
20216
21529
|
const press = degradedPress && degradedPress.el === blockEl ? degradedPress : null;
|
|
20217
21530
|
degradedPress = null;
|
|
20218
|
-
|
|
20219
|
-
|
|
20220
|
-
|
|
20221
|
-
|
|
20222
|
-
|
|
20223
|
-
|
|
20224
|
-
|
|
20225
|
-
|
|
20226
|
-
|
|
20227
|
-
|
|
20228
|
-
|
|
20229
|
-
|
|
20230
|
-
|
|
20231
|
-
|
|
20232
|
-
|
|
20233
|
-
|
|
20234
|
-
|
|
21531
|
+
await openDegradedBlock(blockEl, press, e, null);
|
|
21532
|
+
});
|
|
21533
|
+
// v3.17.0 T9b (R50): the press that loses its click. Its blur committed
|
|
21534
|
+
// what was being typed, and that commit's render replaced the pressed
|
|
21535
|
+
// block before the button came up — so no click follows at all (the trace
|
|
21536
|
+
// above). The release answers for it: when the block the press recorded
|
|
21537
|
+
// has left the document and the button comes up over a degraded block,
|
|
21538
|
+
// the press opens the block it was on, found again by the identity it
|
|
21539
|
+
// recorded, by the same rules as the click (R102 / R104: unique, else the
|
|
21540
|
+
// retry notice). A click that does follow after all is not a second
|
|
21541
|
+
// gesture (`degradedReleaseOpened`, cleared after the release's task).
|
|
21542
|
+
document.addEventListener('mouseup', (e) => {
|
|
21543
|
+
const press = degradedPress;
|
|
21544
|
+
if (!press || press.el.isConnected || e.button !== 0 || e.shiftKey) return;
|
|
21545
|
+
const t = e.target;
|
|
21546
|
+
if (!t || !t.closest || waveEditorIsModal() || t.closest(ED_LIGHTBOX_TARGETS) ||
|
|
21547
|
+
t.closest('.ed-handle, .ed-insert, .ed-handle-menu, .ed-li-check, .ed-te-grip')) return;
|
|
21548
|
+
const upBlock = t.closest('.ed-block');
|
|
21549
|
+
// T10 (M1): the click router's gutter-band rule, the same predicate.
|
|
21550
|
+
if (!upBlock || pressInGutterBand(t, upBlock, e) || !isDegradedBlock(upBlock)) return;
|
|
21551
|
+
degradedPress = null;
|
|
21552
|
+
degradedReleaseOpened = true;
|
|
21553
|
+
// T10 (M2): the flag answers the click of THIS gesture, which comes in
|
|
21554
|
+
// the same task as the release; a later keyboard or programmatic click
|
|
21555
|
+
// has no press of its own and must not be swallowed.
|
|
21556
|
+
setTimeout(() => { degradedReleaseOpened = false; }, 0);
|
|
21557
|
+
if (gutterMenuBlockEl) closeGutterMenu();
|
|
21558
|
+
if (blockSelection) clearBlockSelection();
|
|
21559
|
+
openDegradedBlock(press.el, press, null, upBlock).catch(showUnexpectedFailure);
|
|
20235
21560
|
});
|
|
20236
21561
|
}
|
|
20237
21562
|
|
|
21563
|
+
// v2.11.1 / T10 (M1): was the press on the block's own box, left of it (its
|
|
21564
|
+
// 40 px gutter band)? Read only for a target that is the block itself and an
|
|
21565
|
+
// event that carries coordinates (a synthesized click reports 0, 0). One
|
|
21566
|
+
// predicate for the click router and the mouseup release path.
|
|
21567
|
+
function pressInGutterBand(target, blockEl, ev) {
|
|
21568
|
+
return !!blockEl && target === blockEl && !!(ev.clientX || ev.clientY) &&
|
|
21569
|
+
ev.clientX < blockEl.getBoundingClientRect().left;
|
|
21570
|
+
}
|
|
21571
|
+
|
|
21572
|
+
// The degraded block's open (a raw box, or the in-place code edit), from a
|
|
21573
|
+
// click on it (`e`) or from the release of a press whose click was lost
|
|
21574
|
+
// (`upBlock`, the block the button came up over: the gesture is the
|
|
21575
|
+
// release's only while that is the block found again, or a node a later
|
|
21576
|
+
// render replaced in turn).
|
|
21577
|
+
async function openDegradedBlock(blockEl, press, e, upBlock) {
|
|
21578
|
+
const epochAtPress = press ? press.epoch : reconcileEpoch;
|
|
21579
|
+
const identity = press ? press.identity
|
|
21580
|
+
: (commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null);
|
|
21581
|
+
const topAtClick = blockEl.getBoundingClientRect().top;
|
|
21582
|
+
const ok = await switchAwayFrom();
|
|
21583
|
+
if (!ok) return;
|
|
21584
|
+
await settleCommits();
|
|
21585
|
+
// R104 (final re-review 3, I-R2): found again by content only when it is
|
|
21586
|
+
// the one block of its type with that source — a start line is no proof
|
|
21587
|
+
// (R95's removal of an emptied paragraph shifts lines unlogged, and an
|
|
21588
|
+
// identical quote then sat on the clicked one's line and was opened).
|
|
21589
|
+
const live = refindBlock(blockEl, identity);
|
|
21590
|
+
if (!live || reconcileEpoch !== epochAtPress ||
|
|
21591
|
+
(reconcileBlocksCommits() && !blockReadsAsDrawn(live))) {
|
|
21592
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
21593
|
+
return;
|
|
21594
|
+
}
|
|
21595
|
+
// The release: over another block, or on one something opened meanwhile.
|
|
21596
|
+
if (upBlock && ((live !== upBlock && upBlock.isConnected) ||
|
|
21597
|
+
live.querySelector('.ed-raw, [data-ed-code-edit], .ed-diagram-panel'))) return;
|
|
21598
|
+
// v3.17.0 T2 (D2, R21): a fenced code block edits in place. The click
|
|
21599
|
+
// point places the caret only when the clicked node is still the one on
|
|
21600
|
+
// screen, where it was (R41): a render since the press may have moved or
|
|
21601
|
+
// replaced it, and the point would name other text.
|
|
21602
|
+
if (isFencedCodeBlock(live)) {
|
|
21603
|
+
const samePlace = !!e && live === blockEl && (e.clientX || e.clientY) &&
|
|
21604
|
+
Math.abs(live.getBoundingClientRect().top - topAtClick) < 1;
|
|
21605
|
+
editCodeInPlace(live, samePlace ? { clientX: e.clientX, clientY: e.clientY } : { offset: 0 })
|
|
21606
|
+
.catch(showUnexpectedFailure);
|
|
21607
|
+
return;
|
|
21608
|
+
}
|
|
21609
|
+
openRawEditor(live);
|
|
21610
|
+
}
|
|
21611
|
+
|
|
20238
21612
|
// ── save ───────────────────────────────────────────────────────────────
|
|
20239
21613
|
// v3.10.0: the outside-edit check (onDiskMtime() below) must not read its
|
|
20240
21614
|
// own saves as someone else's edit. A ping answered while a save is in
|
|
@@ -20248,10 +21622,13 @@
|
|
|
20248
21622
|
// conflict card over the user's own save.
|
|
20249
21623
|
let saveGen = 0;
|
|
20250
21624
|
let savesInFlight = 0;
|
|
21625
|
+
let lastSave = null; // v3.17.0 T9b: the newest save's round trip (saveAfterLastSave())
|
|
20251
21626
|
async function save() {
|
|
20252
21627
|
saveGen++;
|
|
20253
21628
|
savesInFlight++;
|
|
20254
|
-
|
|
21629
|
+
const sending = sendSave();
|
|
21630
|
+
lastSave = sending;
|
|
21631
|
+
try { return await sending; } finally { savesInFlight--; }
|
|
20255
21632
|
}
|
|
20256
21633
|
async function sendSave() {
|
|
20257
21634
|
// fix 3: the receipt for the bytes about to leave, taken BEFORE the request
|
|
@@ -20353,6 +21730,24 @@
|
|
|
20353
21730
|
if (ok) return save();
|
|
20354
21731
|
}
|
|
20355
21732
|
|
|
21733
|
+
// v3.17.0 T9b: a save asked for while an earlier one may still be on its
|
|
21734
|
+
// way — a Ctrl+S held through a code edit's or diagram panel's re-entry, a
|
|
21735
|
+
// second Ctrl+S while the first one's resolution is out — is sent once that
|
|
21736
|
+
// one has answered. Sent alongside it, its request carried the baseline the
|
|
21737
|
+
// first reply was about to replace, the server answered 409 (sendSave()'s
|
|
21738
|
+
// note says why) and the conflict card went up over a file this page had
|
|
21739
|
+
// just saved. A queue in the caller, not a lock in sendSave(): those callers
|
|
21740
|
+
// asked for their save while the first was not even sent yet, whereas a
|
|
21741
|
+
// Ctrl+S pressed on its own while a save is out still sends at once
|
|
21742
|
+
// (test/editor-journey.test.js's N5-two-saves drives that path), and no
|
|
21743
|
+
// save waits behind a request that never answers unless it was queued here.
|
|
21744
|
+
function saveAfterLastSave() {
|
|
21745
|
+
const prev = lastSave;
|
|
21746
|
+
const next = (prev ? prev.then(() => {}, () => {}) : Promise.resolve()).then(() => save());
|
|
21747
|
+
lastSave = next;
|
|
21748
|
+
return next;
|
|
21749
|
+
}
|
|
21750
|
+
|
|
20356
21751
|
// v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
|
|
20357
21752
|
// may resolve (commit + re-render) a still-open burst before save() ever
|
|
20358
21753
|
// runs, and that render is exactly the class of render that strands the
|
|
@@ -20620,7 +22015,24 @@
|
|
|
20620
22015
|
// intra-burst mechanism that never reaches this file's undo()/redo() at
|
|
20621
22016
|
// all. Widening either of those was out of this task's scope, the same
|
|
20622
22017
|
// boundary saveViaToolbar()'s own comment draws around Ctrl+S.
|
|
22018
|
+
// (Since v3.17.0 T10 the button does take those two intra-edit routes when
|
|
22019
|
+
// an edit is open, below; Ctrl+Z / Ctrl+Y outside one are unchanged.)
|
|
20623
22020
|
async function undoViaToolbar() {
|
|
22021
|
+
// v3.17.0 T10 (L95b): inside an open code edit the button steps the edit's
|
|
22022
|
+
// own text history, and inside an open paragraph, heading or list item it
|
|
22023
|
+
// steps the burst, exactly as Ctrl+Z does there. It never commits what is
|
|
22024
|
+
// open first and then undoes the whole commit (a transient typed into
|
|
22025
|
+
// vanished entirely that way).
|
|
22026
|
+
if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, -1); return; }
|
|
22027
|
+
const b = currentBurst;
|
|
22028
|
+
if (b && b.editEl && b.blockType !== 'table' && b.editEl.isConnected &&
|
|
22029
|
+
b.editEl.contains(document.activeElement) && burstHasUncommittedEdit()) {
|
|
22030
|
+
// Only with typing to step back through: an untouched burst (an empty
|
|
22031
|
+
// transient left standing after Ctrl+S, say) leaves the button the
|
|
22032
|
+
// document undo it always was.
|
|
22033
|
+
burstUndo(b.editEl);
|
|
22034
|
+
return;
|
|
22035
|
+
}
|
|
20624
22036
|
const saved = captureCaretForSave();
|
|
20625
22037
|
await undo();
|
|
20626
22038
|
restoreCaretForSave(saved);
|
|
@@ -21685,7 +23097,7 @@
|
|
|
21685
23097
|
// carrying the anchor as a stable IDENTITY that is re-resolved when the
|
|
21686
23098
|
// files come back, instead of as a node reference. The identity triple is
|
|
21687
23099
|
// insertBlockBelow()'s (captureBlockIdentity / ownsOpenSession /
|
|
21688
|
-
//
|
|
23100
|
+
// refindBlock + reresolveBlockElAfterSelfCommit) — that function solved
|
|
21689
23101
|
// this same problem for its own anchor and the image path simply never
|
|
21690
23102
|
// adopted it. Do not "simplify" the re-resolution away: it is the half that
|
|
21691
23103
|
// survives the dialog.
|
|
@@ -21707,16 +23119,14 @@
|
|
|
21707
23119
|
if (!blockEl) return;
|
|
21708
23120
|
const identity = captureBlockIdentity(blockEl);
|
|
21709
23121
|
// The commit inside switchAwayFrom() can be a rewrite of THIS block's own
|
|
21710
|
-
// source, in which case
|
|
23122
|
+
// source, in which case refindBlock()'s fingerprint is guaranteed to
|
|
21711
23123
|
// miss because WE are the reason the source changed; the narrowed
|
|
21712
23124
|
// re-resolve is used only then. Same reasoning as insertBlockBelow().
|
|
21713
23125
|
const selfSession = ownsOpenSession(blockEl);
|
|
21714
23126
|
if (!(await switchAwayFrom())) return;
|
|
21715
|
-
const anchorEl = (blockEl
|
|
21716
|
-
|
|
21717
|
-
|
|
21718
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
|
|
21719
|
-
if (!anchorEl) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
|
|
23127
|
+
const anchorEl = refindBlock(blockEl, identity, selfSession);
|
|
23128
|
+
// Fix round 1 (I1, R35): the retry notice, as every re-find refusal.
|
|
23129
|
+
if (!anchorEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
21720
23130
|
// Re-captured AFTER the commit: `identity` above holds the PRE-commit
|
|
21721
23131
|
// fingerprint, which the commit we just awaited may itself have
|
|
21722
23132
|
// invalidated. What has to survive the dialog is the anchor as it stands
|
|
@@ -21729,9 +23139,8 @@
|
|
|
21729
23139
|
// it was — refuse (and skip the upload, so no orphan lands in the
|
|
21730
23140
|
// user's assets/) rather than silently appending at EOF, which is the
|
|
21731
23141
|
// very symptom being fixed.
|
|
21732
|
-
const live =
|
|
21733
|
-
|
|
21734
|
-
if (!live) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
|
|
23142
|
+
const live = refindBlock(anchorEl, dialogIdentity);
|
|
23143
|
+
if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
21735
23144
|
insertImages(files, live);
|
|
21736
23145
|
});
|
|
21737
23146
|
}
|
|
@@ -21739,7 +23148,7 @@
|
|
|
21739
23148
|
// The hidden file input both image paths open; `onFiles` gets the picked
|
|
21740
23149
|
// files (never called for an empty pick). v3.15.0 B1 fix: shared with
|
|
21741
23150
|
// pickImageIntoParagraph().
|
|
21742
|
-
function openImagePicker(onFiles) {
|
|
23151
|
+
function openImagePicker(onFiles, { multiple = true } = {}) {
|
|
21743
23152
|
const input = document.createElement('input');
|
|
21744
23153
|
input.type = 'file';
|
|
21745
23154
|
// v3.4.1: extension tokens sit beside the MIME tokens deliberately.
|
|
@@ -21749,7 +23158,7 @@
|
|
|
21749
23158
|
// button that could not pick one. Mixing the two token kinds is what
|
|
21750
23159
|
// the attribute is specified for.
|
|
21751
23160
|
input.accept = 'image/png,image/jpeg,image/gif,image/webp,.drawio,.xml';
|
|
21752
|
-
input.multiple =
|
|
23161
|
+
input.multiple = multiple;
|
|
21753
23162
|
input.hidden = true;
|
|
21754
23163
|
// (a) in pickAndInsertImage()'s comment.
|
|
21755
23164
|
input.addEventListener('click', (e) => e.stopPropagation());
|
|
@@ -21807,6 +23216,14 @@
|
|
|
21807
23216
|
if (!images.length) return; // not ours: let the page do whatever it would
|
|
21808
23217
|
e.preventDefault();
|
|
21809
23218
|
const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
|
|
23219
|
+
// D18 (v3.17.0): a raster picture dropped ON a picture replaces it (the
|
|
23220
|
+
// first file only, one op). A .drawio drop keeps inserting.
|
|
23221
|
+
if (anchorEl && isImageBlock(anchorEl) && isRasterImageFile(images[0])) {
|
|
23222
|
+
// v3.17.0 T9a (R61): a throw (an upload that rejects) is reported,
|
|
23223
|
+
// never left as an unhandled rejection.
|
|
23224
|
+
replaceImageIn(anchorEl, images[0]).catch(showUnexpectedFailure);
|
|
23225
|
+
return;
|
|
23226
|
+
}
|
|
21810
23227
|
insertImages(images, anchorEl || liveToolbarBlock() || lastBlockEl());
|
|
21811
23228
|
});
|
|
21812
23229
|
}
|
|
@@ -22341,6 +23758,13 @@
|
|
|
22341
23758
|
// — nothing is duplicated here beyond this one line shape, and
|
|
22342
23759
|
// save()'s own body is still called exactly once, from inside
|
|
22343
23760
|
// commitThenSave() when the toolbar path runs it.
|
|
23761
|
+
//
|
|
23762
|
+
// v3.17.0 T9b: a resolution already out (a second Ctrl+S while the
|
|
23763
|
+
// first one's commit renders): this press waits for the same
|
|
23764
|
+
// resolution and then saves after the newest save, never alongside it.
|
|
23765
|
+
// Both presses used to get the one cached `switching` promise and run
|
|
23766
|
+
// two save()s back to back, and the second 409'd into the conflict card.
|
|
23767
|
+
if (switching) { switching.then((ok) => { if (ok) saveAfterLastSave(); }); return; }
|
|
22344
23768
|
switchAwayFrom().then((ok) => { if (ok) save(); });
|
|
22345
23769
|
return;
|
|
22346
23770
|
}
|
|
@@ -22349,6 +23773,11 @@
|
|
|
22349
23773
|
// Ctrl+Z / Ctrl+Y undo its own text, never the document. Ahead of every
|
|
22350
23774
|
// undo/redo branch below, which is left as it was.
|
|
22351
23775
|
if (e.target && e.target.closest && e.target.closest('.ed-linkbar')) return;
|
|
23776
|
+
// v3.17.0 T2 (R25, D3): the same for the in-place code edit — its own
|
|
23777
|
+
// keydown listener has already answered Enter / Tab / Esc / undo / redo,
|
|
23778
|
+
// and nothing below (the document undo, Tab, the block keys) may act on
|
|
23779
|
+
// the rest. Ctrl+S above still commits it and saves.
|
|
23780
|
+
if (ownsOwnKeys(e.target)) return;
|
|
22352
23781
|
// v3.15.0 T5: Ctrl/Cmd+K opens the URL field.
|
|
22353
23782
|
if (linkShortcutKeydown(e)) return;
|
|
22354
23783
|
|
|
@@ -22526,7 +23955,15 @@
|
|
|
22526
23955
|
if (from && members.length === 1 && !waveDiagramInside(from)) {
|
|
22527
23956
|
e.preventDefault();
|
|
22528
23957
|
clearBlockSelection();
|
|
22529
|
-
|
|
23958
|
+
// A Graphviz / Mermaid block opens its panel; a dot block whose
|
|
23959
|
+
// bake failed is a plain code block and takes the raw box.
|
|
23960
|
+
// v3.17.0 T9a (R60): a throw is reported like the 編輯圖表 button's,
|
|
23961
|
+
// and a block that turns out not to open a panel gets its
|
|
23962
|
+
// selection back instead of leaving nothing selected and nothing
|
|
23963
|
+
// open.
|
|
23964
|
+
if (isDiagramBlock(from) !== null) {
|
|
23965
|
+
openDiagramPanelFromGesture(from, { reselect: true }).catch(showUnexpectedFailure);
|
|
23966
|
+
} else openRawViaGutter(from);
|
|
22530
23967
|
return;
|
|
22531
23968
|
}
|
|
22532
23969
|
}
|
|
@@ -22824,6 +24261,20 @@
|
|
|
22824
24261
|
// own per-instance listener (unchanged); this adds the blur trigger on
|
|
22825
24262
|
// top of it, through the same switchAwayFrom()/activeEditor path every
|
|
22826
24263
|
// other commit route in this file already uses.
|
|
24264
|
+
// v3.17.0 T2: focus leaving the in-place code edit resolves it through the
|
|
24265
|
+
// same switchAwayFrom() — committed when changed, ended with no write when
|
|
24266
|
+
// not. A session a render or its own commit already let go of (it cleared
|
|
24267
|
+
// `activeEditor` before the write that blurred the host) is left alone.
|
|
24268
|
+
const codeHost = e.target && e.target.matches && e.target.matches('pre[data-ed-code-edit]') ? e.target : null;
|
|
24269
|
+
if (codeHost) {
|
|
24270
|
+
const s = codeEditState;
|
|
24271
|
+
if (!s || s.host !== codeHost || activeEditor !== s.editor) return;
|
|
24272
|
+
if (e.relatedTarget && codeHost.contains(e.relatedTarget)) return;
|
|
24273
|
+
// v3.17.0 T3: the language filter takes focus without ending the edit.
|
|
24274
|
+
if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.ed-code-langmenu')) return;
|
|
24275
|
+
switchAwayFrom();
|
|
24276
|
+
return;
|
|
24277
|
+
}
|
|
22827
24278
|
const ta = e.target && e.target.matches && e.target.matches('textarea.ed-raw') ? e.target : null;
|
|
22828
24279
|
if (!ta) return;
|
|
22829
24280
|
const blockEl = ta.closest('.ed-block');
|
|
@@ -23247,9 +24698,10 @@
|
|
|
23247
24698
|
// v3.15.0 T1 (R24): a scroll closes the "/" menu — except the menu's own
|
|
23248
24699
|
// list scrolling its cursor into view.
|
|
23249
24700
|
if (slashMenu && !(e && e.target && e.target.nodeType === 1 && slashMenu.el.contains(e.target))) closeSlashMenu();
|
|
23250
|
-
// v3.15.0 T5 (R24)
|
|
23251
|
-
// selection
|
|
23252
|
-
|
|
24701
|
+
// v3.15.0 T5 (R24), changed in v3.17.0 T10 (M-c): the URL field follows
|
|
24702
|
+
// its selection and closes (back to the toolbar over the selection, which
|
|
24703
|
+
// the rest of this function then repositions) only when it left the view.
|
|
24704
|
+
if (linkFieldState) followLinkField();
|
|
23253
24705
|
// v3.15.0 T6 (R24): and the link card.
|
|
23254
24706
|
hideLinkCard();
|
|
23255
24707
|
if (tePointer && tePointer.dragging) return; // 拖曳中不動
|
|
@@ -23945,6 +25397,7 @@
|
|
|
23945
25397
|
|
|
23946
25398
|
armEditables(contentEl);
|
|
23947
25399
|
wireBlockSelection();
|
|
25400
|
+
wireCodeEditReenterHold();
|
|
23948
25401
|
|
|
23949
25402
|
// ── v3.4.0 batch3 Task 6: the wavedrom editing surface ─────────────────
|
|
23950
25403
|
//
|
|
@@ -24156,6 +25609,906 @@
|
|
|
24156
25609
|
}, true);
|
|
24157
25610
|
window.addEventListener('scroll', () => { if (waveEditor === null) hideWaveEditBtn(); }, true);
|
|
24158
25611
|
|
|
25612
|
+
// ── v3.17.0 T4: the 編輯圖表 button on Graphviz and Mermaid blocks (E8) ──
|
|
25613
|
+
// A fixed singleton on document.body, never inside .content (the wave
|
|
25614
|
+
// button's reason), at the top-right of the diagram the pointer is over. A
|
|
25615
|
+
// diagram block is a fenced block whose rendered first child is the
|
|
25616
|
+
// graphviz or mermaid div; it has no code chips (those need a pre), so the
|
|
25617
|
+
// two hover paths cannot both fire on one block. The click opens the
|
|
25618
|
+
// diagram panel (openDiagramPanel(), T5).
|
|
25619
|
+
const diagramEditBtn = document.createElement('button');
|
|
25620
|
+
diagramEditBtn.type = 'button';
|
|
25621
|
+
diagramEditBtn.className = 'ed-diagram-edit-btn';
|
|
25622
|
+
diagramEditBtn.title = '編輯這張圖表的原始碼,並即時預覽';
|
|
25623
|
+
diagramEditBtn.hidden = true;
|
|
25624
|
+
diagramEditBtn.insertAdjacentHTML('beforeend', codeChipIcon('pencil'));
|
|
25625
|
+
const diagramEditLabel = document.createElement('span');
|
|
25626
|
+
diagramEditLabel.textContent = '編輯圖表';
|
|
25627
|
+
diagramEditBtn.appendChild(diagramEditLabel);
|
|
25628
|
+
document.body.appendChild(diagramEditBtn);
|
|
25629
|
+
let diagramEditBlock = null; // the .ed-block the button is drawn for
|
|
25630
|
+
|
|
25631
|
+
// 'dot' | 'mermaid' | null. An allow-list on the rendered first child: a
|
|
25632
|
+
// dot block whose bake failed renders a pre and is an ordinary code block.
|
|
25633
|
+
function isDiagramBlock(blockEl) {
|
|
25634
|
+
if (!blockEl || typeof blockEl.getAttribute !== 'function' ||
|
|
25635
|
+
blockEl.getAttribute('data-block-type') !== 'code') return null;
|
|
25636
|
+
const first = blockEl.firstElementChild;
|
|
25637
|
+
if (!first || !first.classList) return null;
|
|
25638
|
+
if (first.classList.contains('graphviz')) return 'dot';
|
|
25639
|
+
if (first.classList.contains('mermaid')) return 'mermaid';
|
|
25640
|
+
return null;
|
|
25641
|
+
}
|
|
25642
|
+
|
|
25643
|
+
|
|
25644
|
+
// After a render: a block the render replaced takes its button with it.
|
|
25645
|
+
function reconcileDiagramEditBtn() {
|
|
25646
|
+
if (diagramEditBlock !== null && !diagramEditBlock.isConnected) hideDiagramEditBtn();
|
|
25647
|
+
}
|
|
25648
|
+
|
|
25649
|
+
function hideDiagramEditBtn() {
|
|
25650
|
+
if (diagramEditBlock === null && diagramEditBtn.hidden) return;
|
|
25651
|
+
diagramEditBlock = null;
|
|
25652
|
+
diagramEditBtn.hidden = true;
|
|
25653
|
+
}
|
|
25654
|
+
|
|
25655
|
+
function showDiagramEditBtnFor(blockEl) {
|
|
25656
|
+
if (waveEditorIsModal() || !blockEl || !blockEl.isConnected || isDiagramBlock(blockEl) === null ||
|
|
25657
|
+
blockEl.querySelector('.ed-raw, .ed-diagram-panel')) { hideDiagramEditBtn(); return; }
|
|
25658
|
+
if (diagramEditBlock === blockEl && !diagramEditBtn.hidden) return;
|
|
25659
|
+
const r = blockEl.firstElementChild.getBoundingClientRect();
|
|
25660
|
+
if (r.width === 0 && r.height === 0) { hideDiagramEditBtn(); return; }
|
|
25661
|
+
diagramEditBlock = blockEl;
|
|
25662
|
+
diagramEditBtn.hidden = false;
|
|
25663
|
+
// The wave button's formula: nudged inside the diagram, never under the
|
|
25664
|
+
// toolbar band, never off the viewport.
|
|
25665
|
+
diagramEditBtn.style.top = Math.round(Math.max(toolbarTopInset() + 4, r.top + 6)) + 'px';
|
|
25666
|
+
diagramEditBtn.style.left = Math.round(Math.max(4,
|
|
25667
|
+
Math.min(r.right - diagramEditBtn.offsetWidth - 6,
|
|
25668
|
+
document.documentElement.clientWidth - diagramEditBtn.offsetWidth - 6))) + 'px';
|
|
25669
|
+
}
|
|
25670
|
+
|
|
25671
|
+
document.addEventListener('mouseover', (ev) => {
|
|
25672
|
+
const t = ev.target;
|
|
25673
|
+
if (!t || typeof t.closest !== 'function') return;
|
|
25674
|
+
if (t.closest('.ed-diagram-edit-btn')) return;
|
|
25675
|
+
const blk = t.closest('.ed-block[data-block-type="code"]');
|
|
25676
|
+
if (blk && isDiagramBlock(blk) !== null) showDiagramEditBtnFor(blk); else hideDiagramEditBtn();
|
|
25677
|
+
}, true);
|
|
25678
|
+
document.addEventListener('scroll', () => { hideDiagramEditBtn(); }, { passive: true, capture: true });
|
|
25679
|
+
window.addEventListener('resize', () => { hideDiagramEditBtn(); });
|
|
25680
|
+
// A press on the button never moves focus; the click opens the panel.
|
|
25681
|
+
diagramEditBtn.addEventListener('mousedown', (e) => { e.preventDefault(); });
|
|
25682
|
+
diagramEditBtn.addEventListener('click', () => {
|
|
25683
|
+
const blk = diagramEditBlock;
|
|
25684
|
+
if (!blk || !blk.isConnected) { hideDiagramEditBtn(); return; }
|
|
25685
|
+
hideDiagramEditBtn();
|
|
25686
|
+
openDiagramPanelFromGesture(blk).catch(showUnexpectedFailure);
|
|
25687
|
+
});
|
|
25688
|
+
|
|
25689
|
+
// ── v3.17.0 T7: the image toolbar (D16, D18) ──────────────────────────
|
|
25690
|
+
//
|
|
25691
|
+
// A paragraph that is one image and nothing else (isImageBlock) gets 替換,
|
|
25692
|
+
// 替代文字 and 刪除 while the pointer is over it: a fixed singleton on
|
|
25693
|
+
// document.body, never inside .content, 8 px inside the image's top-left,
|
|
25694
|
+
// the link card's look. It goes 300 ms after the pointer leaves both the
|
|
25695
|
+
// image block and itself, and at once on scroll, resize and render. Every
|
|
25696
|
+
// press on it calls preventDefault() on mousedown (the edit keeps focus)
|
|
25697
|
+
// except the alt-text input, which takes it. 替換 and 替代文字 rewrite
|
|
25698
|
+
// only the destination or the brackets of the one source line
|
|
25699
|
+
// (inlineMd.rewriteImage, D17), as one op each; 刪除 is the block's own
|
|
25700
|
+
// delete. A click on the picture itself still opens the lightbox: the
|
|
25701
|
+
// toolbar sits over the image's corner, it does not change the click
|
|
25702
|
+
// routing.
|
|
25703
|
+
const IMGTB_INSET_PX = 8;
|
|
25704
|
+
const IMGTB_HIDE_MS = 300;
|
|
25705
|
+
const IMGTB_NOT_RASTER_MESSAGE = '只能用圖片檔替換';
|
|
25706
|
+
const imgTb = document.createElement('div');
|
|
25707
|
+
imgTb.className = 'ed-imgtb';
|
|
25708
|
+
imgTb.hidden = true;
|
|
25709
|
+
const imgTbButtons = document.createElement('div');
|
|
25710
|
+
imgTbButtons.className = 'ed-imgtb-btns';
|
|
25711
|
+
imgTb.appendChild(imgTbButtons);
|
|
25712
|
+
function addImgTbButton(label, extraClass, onClick) {
|
|
25713
|
+
const b = document.createElement('button');
|
|
25714
|
+
b.type = 'button';
|
|
25715
|
+
b.className = 'ed-imgtb-btn' + (extraClass ? ' ' + extraClass : '');
|
|
25716
|
+
b.textContent = label;
|
|
25717
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
25718
|
+
imgTbButtons.appendChild(b);
|
|
25719
|
+
return b;
|
|
25720
|
+
}
|
|
25721
|
+
const imgTbInput = document.createElement('input');
|
|
25722
|
+
imgTbInput.type = 'text';
|
|
25723
|
+
imgTbInput.className = 'ed-imgtb-input';
|
|
25724
|
+
imgTbInput.placeholder = '替代文字';
|
|
25725
|
+
imgTbInput.setAttribute('aria-label', '替代文字');
|
|
25726
|
+
imgTbInput.hidden = true;
|
|
25727
|
+
imgTb.appendChild(imgTbInput);
|
|
25728
|
+
document.body.appendChild(imgTb);
|
|
25729
|
+
let imgTbBlock = null; // the .ed-block the toolbar is drawn for
|
|
25730
|
+
let imgTbHideTimer = null;
|
|
25731
|
+
let imgTbAlt = null; // { identity, original } while the input is showing
|
|
25732
|
+
|
|
25733
|
+
// An image block: a paragraph whose source is one image (D16) and whose
|
|
25734
|
+
// rendered content is exactly one img. .drawio / .xml keep their own flow.
|
|
25735
|
+
function isImageBlock(blockEl) {
|
|
25736
|
+
if (!blockEl || typeof blockEl.getAttribute !== 'function' || !inlineMd ||
|
|
25737
|
+
typeof inlineMd.parseImageParagraph !== 'function' ||
|
|
25738
|
+
blockEl.getAttribute('data-block-type') !== 'paragraph') return false;
|
|
25739
|
+
const rec = blockRecOf(blockEl);
|
|
25740
|
+
if (!rec || rec.endLine < rec.startLine) return false;
|
|
25741
|
+
const parsed = inlineMd.parseImageParagraph(blockSourceOf(rec));
|
|
25742
|
+
if (!parsed || /\.(drawio|xml)([?#].*)?$/i.test(parsed.dest)) return false;
|
|
25743
|
+
const surface = blockContentEl(blockEl);
|
|
25744
|
+
return !!surface && surface.children.length === 1 && surface.firstElementChild.tagName === 'IMG' &&
|
|
25745
|
+
surface.textContent.trim() === '';
|
|
25746
|
+
}
|
|
25747
|
+
// A pick for 替換 must be a picture a browser draws; the picker's list also
|
|
25748
|
+
// names .drawio and .xml, which belong to the insert flow.
|
|
25749
|
+
function isRasterImageFile(file) {
|
|
25750
|
+
const type = String((file && file.type) || '');
|
|
25751
|
+
if (type) return /^image\/(png|jpe?g|gif|webp)$/i.test(type);
|
|
25752
|
+
return /\.(png|jpe?g|gif|webp)$/i.test(String((file && file.name) || ''));
|
|
25753
|
+
}
|
|
25754
|
+
|
|
25755
|
+
function cancelImageToolbarHide() {
|
|
25756
|
+
if (imgTbHideTimer !== null) { clearTimeout(imgTbHideTimer); imgTbHideTimer = null; }
|
|
25757
|
+
}
|
|
25758
|
+
function scheduleImageToolbarHide() {
|
|
25759
|
+
if (imgTb.hidden || imgTbAlt || imgTbHideTimer !== null) return;
|
|
25760
|
+
imgTbHideTimer = setTimeout(() => { imgTbHideTimer = null; if (!imgTbAlt) hideImageToolbar(); }, IMGTB_HIDE_MS);
|
|
25761
|
+
}
|
|
25762
|
+
function hideImageToolbar() {
|
|
25763
|
+
cancelImageToolbarHide();
|
|
25764
|
+
const wasAlt = imgTbAlt;
|
|
25765
|
+
imgTbAlt = null;
|
|
25766
|
+
imgTbBlock = null;
|
|
25767
|
+
if (wasAlt) {
|
|
25768
|
+
imgTbInput.hidden = true;
|
|
25769
|
+
imgTbButtons.hidden = false;
|
|
25770
|
+
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
25771
|
+
}
|
|
25772
|
+
if (imgTb.hidden) return;
|
|
25773
|
+
imgTb.hidden = true;
|
|
25774
|
+
}
|
|
25775
|
+
function reconcileImageToolbar() {
|
|
25776
|
+
if (imgTbBlock !== null && !imgTbBlock.isConnected) hideImageToolbar();
|
|
25777
|
+
}
|
|
25778
|
+
function placeImageToolbar(blockEl) {
|
|
25779
|
+
const img = blockEl.querySelector('img');
|
|
25780
|
+
const r = img ? img.getBoundingClientRect() : null;
|
|
25781
|
+
if (!r || (r.width === 0 && r.height === 0)) { hideImageToolbar(); return false; }
|
|
25782
|
+
imgTb.hidden = false;
|
|
25783
|
+
const top = Math.max(r.top + IMGTB_INSET_PX, toolbarTopInset() + 4);
|
|
25784
|
+
if (top > r.bottom - 4) { hideImageToolbar(); return false; }
|
|
25785
|
+
imgTb.style.top = Math.round(top) + 'px';
|
|
25786
|
+
imgTb.style.left = Math.round(Math.max(4, Math.min(r.left + IMGTB_INSET_PX,
|
|
25787
|
+
document.documentElement.clientWidth - imgTb.offsetWidth - 4))) + 'px';
|
|
25788
|
+
return true;
|
|
25789
|
+
}
|
|
25790
|
+
function showImageToolbarFor(blockEl) {
|
|
25791
|
+
if (waveEditorIsModal() || !blockEl || !blockEl.isConnected || !isImageBlock(blockEl)) { hideImageToolbar(); return; }
|
|
25792
|
+
cancelImageToolbarHide();
|
|
25793
|
+
if (imgTbAlt && imgTbBlock !== blockEl) return; // the input owns the toolbar
|
|
25794
|
+
if (imgTbBlock === blockEl && !imgTb.hidden) return;
|
|
25795
|
+
imgTbBlock = blockEl;
|
|
25796
|
+
placeImageToolbar(blockEl);
|
|
25797
|
+
}
|
|
25798
|
+
|
|
25799
|
+
// The one path that writes an image edit: the block found again after
|
|
25800
|
+
// everything that could have moved it (R104: by source, and only when it is
|
|
25801
|
+
// the one such block), one commitRangeEdit over its line, nothing written
|
|
25802
|
+
// when the source reads the same. false: refused or the render failed.
|
|
25803
|
+
async function applyImageEdit(blockEl, identity, patch) {
|
|
25804
|
+
if (!identity || !inlineMd) return false;
|
|
25805
|
+
await settleCommits();
|
|
25806
|
+
const ok = await switchAwayFrom();
|
|
25807
|
+
if (!ok) return false;
|
|
25808
|
+
let el = blockEl && blockEl.isConnected ? blockEl : null;
|
|
25809
|
+
let rec = el ? blockRecOf(el) : null;
|
|
25810
|
+
if (!rec || blockSourceOf(rec) !== identity.source || identity.reconcileEpoch !== reconcileEpoch) {
|
|
25811
|
+
el = uniqueBlockElBySource(identity);
|
|
25812
|
+
rec = el ? blockRecOf(el) : null;
|
|
25813
|
+
}
|
|
25814
|
+
if (!el || !rec || reconcileBlocksCommits()) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
25815
|
+
const source = blockSourceOf(rec);
|
|
25816
|
+
const next = inlineMd.rewriteImage(source, patch);
|
|
25817
|
+
if (next === source) return true;
|
|
25818
|
+
const r = await commitFencedRangeAndRender(rec.startLine, rec.endLine, next);
|
|
25819
|
+
return r === true;
|
|
25820
|
+
}
|
|
25821
|
+
|
|
25822
|
+
// 替換 (and T8's drop): the picked file replaces the picture, keeping alt
|
|
25823
|
+
// and title. A file that is not a raster picture writes nothing (R39).
|
|
25824
|
+
//
|
|
25825
|
+
// `identity` (final review I1): 替換 passes the block as captured at the
|
|
25826
|
+
// click, from a node on screen then (pickReplacementImage()). The node held
|
|
25827
|
+
// across the file dialog may since have been replaced by a render, and the
|
|
25828
|
+
// data-block-id it still carries then names whatever block holds that id
|
|
25829
|
+
// NOW: measured before this, a paragraph committed above two pictures while
|
|
25830
|
+
// the dialog was open, and the pick replaced the picture ABOVE the one
|
|
25831
|
+
// pointed at, with no notice (or, on a block that is not a picture, was
|
|
25832
|
+
// ignored, its upload left behind). So the block is found again with
|
|
25833
|
+
// refindBlock(), the one rule the rest of the editor uses, and a block that
|
|
25834
|
+
// cannot be found is refused with the retry notice before anything is
|
|
25835
|
+
// uploaded. A drop passes no identity: its node is on screen at the drop.
|
|
25836
|
+
async function replaceImageIn(blockEl, file, identity) {
|
|
25837
|
+
if (!blockEl || !file) return false;
|
|
25838
|
+
if (!isRasterImageFile(file)) { showNotice(IMGTB_NOT_RASTER_MESSAGE); return false; }
|
|
25839
|
+
let el = null;
|
|
25840
|
+
if (identity) {
|
|
25841
|
+
await settleCommits();
|
|
25842
|
+
el = refindBlock(blockEl, identity);
|
|
25843
|
+
} else {
|
|
25844
|
+
el = await settledGestureBlock(blockEl);
|
|
25845
|
+
if (!el) return false; // refused, and said so, by settledGestureBlock()
|
|
25846
|
+
}
|
|
25847
|
+
const live = el && el.isConnected && isImageBlock(el) ? captureBlockIdentity(el) : null;
|
|
25848
|
+
if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
25849
|
+
const path = await uploadImageBlob(file);
|
|
25850
|
+
if (!path) return false;
|
|
25851
|
+
return applyImageEdit(el, live, { dest: markdownImageDest(path) });
|
|
25852
|
+
}
|
|
25853
|
+
|
|
25854
|
+
function exitImageAltMode() {
|
|
25855
|
+
if (!imgTbAlt) return;
|
|
25856
|
+
imgTbAlt = null;
|
|
25857
|
+
imgTbInput.hidden = true;
|
|
25858
|
+
imgTbButtons.hidden = false;
|
|
25859
|
+
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
25860
|
+
if (imgTbBlock) placeImageToolbar(imgTbBlock);
|
|
25861
|
+
scheduleImageToolbarHide();
|
|
25862
|
+
}
|
|
25863
|
+
async function openImageAltMode() {
|
|
25864
|
+
const first = imgTbBlock;
|
|
25865
|
+
if (!first || imgTbAlt) return;
|
|
25866
|
+
cancelImageToolbarHide();
|
|
25867
|
+
const el = await settledGestureBlock(first);
|
|
25868
|
+
if (!el) { hideImageToolbar(); return; }
|
|
25869
|
+
const identity = captureBlockIdentity(el);
|
|
25870
|
+
const ok = await switchAwayFrom();
|
|
25871
|
+
if (!ok) { hideImageToolbar(); return; }
|
|
25872
|
+
const live = el.isConnected ? el : uniqueBlockElBySource(identity);
|
|
25873
|
+
if (!live || !isImageBlock(live)) { hideImageToolbar(); showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
25874
|
+
const parsed = inlineMd.parseImageParagraph(blockSourceOf(blockRecOf(live)));
|
|
25875
|
+
imgTbBlock = live;
|
|
25876
|
+
imgTbAlt = { identity: captureBlockIdentity(live), original: parsed.alt };
|
|
25877
|
+
imgTbButtons.hidden = true;
|
|
25878
|
+
imgTbInput.hidden = false;
|
|
25879
|
+
imgTbInput.value = parsed.alt;
|
|
25880
|
+
if (!placeImageToolbar(live)) return;
|
|
25881
|
+
imgTbInput.focus({ preventScroll: true });
|
|
25882
|
+
imgTbInput.select();
|
|
25883
|
+
}
|
|
25884
|
+
imgTbInput.addEventListener('keydown', (e) => {
|
|
25885
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitImageAltMode(); return; }
|
|
25886
|
+
if (e.key !== 'Enter' || e.isComposing) return;
|
|
25887
|
+
e.preventDefault();
|
|
25888
|
+
e.stopPropagation();
|
|
25889
|
+
const alt = imgTbAlt;
|
|
25890
|
+
const blk = imgTbBlock;
|
|
25891
|
+
const value = imgTbInput.value;
|
|
25892
|
+
hideImageToolbar();
|
|
25893
|
+
if (!alt || value === alt.original) return;
|
|
25894
|
+
applyImageEdit(blk, alt.identity, { alt: value }).catch(showUnexpectedFailure);
|
|
25895
|
+
});
|
|
25896
|
+
imgTbInput.addEventListener('blur', () => { if (imgTbAlt) exitImageAltMode(); });
|
|
25897
|
+
|
|
25898
|
+
// Final review I1: the 🖼 path's order (pickAndInsertImage()). The press
|
|
25899
|
+
// resolves any open session first and takes the block's identity while its
|
|
25900
|
+
// node is on screen; the dialog's callback finds the block again by that
|
|
25901
|
+
// identity (replaceImageIn()), never by the node it held across the dialog.
|
|
25902
|
+
async function pickReplacementImage(blk) {
|
|
25903
|
+
const el = await settledGestureBlock(blk);
|
|
25904
|
+
if (!el) return;
|
|
25905
|
+
const identity = captureBlockIdentity(el);
|
|
25906
|
+
const selfSession = ownsOpenSession(el);
|
|
25907
|
+
if (!(await switchAwayFrom())) return;
|
|
25908
|
+
const live = refindBlock(el, identity, selfSession);
|
|
25909
|
+
if (!live || !isImageBlock(live)) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
25910
|
+
const dialogIdentity = captureBlockIdentity(live);
|
|
25911
|
+
openImagePicker(async (files) => { await replaceImageIn(live, files[0], dialogIdentity); }, { multiple: false });
|
|
25912
|
+
}
|
|
25913
|
+
addImgTbButton('替換', '', () => {
|
|
25914
|
+
const blk = imgTbBlock;
|
|
25915
|
+
hideImageToolbar();
|
|
25916
|
+
if (!blk || !blk.isConnected) return;
|
|
25917
|
+
pickReplacementImage(blk).catch(showUnexpectedFailure);
|
|
25918
|
+
});
|
|
25919
|
+
addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
|
|
25920
|
+
addImgTbButton('刪除', 'ed-imgtb-delete', () => {
|
|
25921
|
+
const blk = imgTbBlock;
|
|
25922
|
+
hideImageToolbar();
|
|
25923
|
+
if (!blk || !blk.isConnected) return;
|
|
25924
|
+
clearBlockSelection();
|
|
25925
|
+
deleteBlockViaGutter(blk).catch(showUnexpectedFailure);
|
|
25926
|
+
});
|
|
25927
|
+
|
|
25928
|
+
// Presses keep focus where it is; the alt input is the one thing that takes it.
|
|
25929
|
+
imgTb.addEventListener('mousedown', (e) => { if (e.target !== imgTbInput) e.preventDefault(); });
|
|
25930
|
+
document.addEventListener('mouseover', (ev) => {
|
|
25931
|
+
const t = ev.target;
|
|
25932
|
+
if (!t || typeof t.closest !== 'function' || imgTbAlt) return;
|
|
25933
|
+
if (t.closest('.ed-imgtb')) { cancelImageToolbarHide(); return; }
|
|
25934
|
+
const blk = t.closest('.ed-block[data-block-type="paragraph"]');
|
|
25935
|
+
if (blk && isImageBlock(blk)) showImageToolbarFor(blk); else scheduleImageToolbarHide();
|
|
25936
|
+
}, true);
|
|
25937
|
+
document.documentElement.addEventListener('mouseleave', () => { scheduleImageToolbarHide(); });
|
|
25938
|
+
document.addEventListener('scroll', (ev) => {
|
|
25939
|
+
if (ev.target === imgTbInput || imgTb.contains(ev.target)) return;
|
|
25940
|
+
hideImageToolbar();
|
|
25941
|
+
}, { passive: true, capture: true });
|
|
25942
|
+
window.addEventListener('resize', () => { hideImageToolbar(); });
|
|
25943
|
+
|
|
25944
|
+
// ── v3.17.0 T5: the diagram panel — the source and a live preview (D15) ──
|
|
25945
|
+
//
|
|
25946
|
+
// 編輯圖表 replaces the block's content in place, the raw box's way (the
|
|
25947
|
+
// rendered HTML is kept to put back on Esc): a header (Graphviz / Mermaid,
|
|
25948
|
+
// the hint, 完成), the source in a textarea, the preview, and one message
|
|
25949
|
+
// line under it. Inside the block, never a fixed overlay on body: the panel
|
|
25950
|
+
// is the block while it is open.
|
|
25951
|
+
//
|
|
25952
|
+
// It is an `activeEditor`, so switchAwayFrom(), a click elsewhere, undo /
|
|
25953
|
+
// redo and the flight guard resolve it as they resolve the raw box and the
|
|
25954
|
+
// in-place code edit. 完成, Ctrl/Cmd+Enter and focus leaving the panel
|
|
25955
|
+
// commit: one commitRangeEdit over the whole fenced range, the fence lines
|
|
25956
|
+
// and info string kept (unchanged → no op, no undo step). Esc discards. Its
|
|
25957
|
+
// commit keeps the eight obligations of openRawEditor()'s commit() and
|
|
25958
|
+
// cancelAndMaybeDiscard() (R27), through commitFencedRangeAndRender(), which
|
|
25959
|
+
// it shares with the code edit. Ctrl+S commits, saves and opens the panel
|
|
25960
|
+
// again where the caret was (R48 / R56 (c)).
|
|
25961
|
+
//
|
|
25962
|
+
// Preview (D15): 500 ms after the last input, and once on open. Graphviz
|
|
25963
|
+
// asks POST /api/preview (stateless: never /api/render, which records the
|
|
25964
|
+
// document's draw.io references); Mermaid renders in the browser through
|
|
25965
|
+
// md2docTheme.renderMermaid(), on the theme runtime's serial chain (D13). A
|
|
25966
|
+
// sequence number drops every answer but the newest request's, so a slow
|
|
25967
|
+
// older answer never replaces a newer one. An error, or an answer with
|
|
25968
|
+
// nothing to draw (an empty source, an empty svg: R56 (a)), keeps the last
|
|
25969
|
+
// good svg under .is-stale and says so on the message line. The preview
|
|
25970
|
+
// carries neither .graphviz nor .mermaid: the lightbox, adoptMermaids() and
|
|
25971
|
+
// the recolour observer key on those. Graphviz is recoloured here, and again
|
|
25972
|
+
// on a theme switch; Mermaid is drawn again in the new theme.
|
|
25973
|
+
const DIAGRAM_PREVIEW_MS = 500;
|
|
25974
|
+
const DIAGRAM_PREVIEW_FAILED = '預覽失敗(伺服器沒有回應)';
|
|
25975
|
+
const DIAGRAM_PREVIEW_EMPTY = '沒有可預覽的內容';
|
|
25976
|
+
const DIAGRAM_NO_MERMAID = '預覽失敗(Mermaid 引擎沒有載入)';
|
|
25977
|
+
const DIAGRAM_LANG_LABEL = { dot: 'Graphviz', mermaid: 'Mermaid' };
|
|
25978
|
+
|
|
25979
|
+
// The open panel, or null. A render that replaced its block, or cleared
|
|
25980
|
+
// `activeEditor` for it, has ended it; its timer, listeners and theme
|
|
25981
|
+
// subscription go with it.
|
|
25982
|
+
function liveDiagramPanel() {
|
|
25983
|
+
const s = diagramPanelState;
|
|
25984
|
+
if (!s) return null;
|
|
25985
|
+
if (activeEditor === s.editor && s.panel.isConnected) return s;
|
|
25986
|
+
diagramPanelState = null;
|
|
25987
|
+
disposeDiagramPanel(s);
|
|
25988
|
+
return null;
|
|
25989
|
+
}
|
|
25990
|
+
function disposeDiagramPanel(s) {
|
|
25991
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
25992
|
+
s.previewSeq++; // an answer still out lands nowhere
|
|
25993
|
+
s.listeners.abort();
|
|
25994
|
+
if (s.offTheme) { s.offTheme(); s.offTheme = null; }
|
|
25995
|
+
}
|
|
25996
|
+
function diagramThemeIsDark() {
|
|
25997
|
+
const t = window.md2docTheme;
|
|
25998
|
+
return !!(t && typeof t.isDark === 'function' && t.isDark());
|
|
25999
|
+
}
|
|
26000
|
+
function firstLineOf(text) {
|
|
26001
|
+
return String(text === undefined || text === null ? '' : text).split('\n')[0].trim();
|
|
26002
|
+
}
|
|
26003
|
+
|
|
26004
|
+
// The block's lines for `text`: the opening and closing fence lines as they
|
|
26005
|
+
// were, unless the text holds a run of the fence character that needs a
|
|
26006
|
+
// longer fence (D10's rule); an unterminated fence gets its closing line.
|
|
26007
|
+
function diagramFenceLines(s, text) {
|
|
26008
|
+
const f = s.fence;
|
|
26009
|
+
const body = text.split('\n');
|
|
26010
|
+
const built = codeEditLib.buildFence({ indent: f.indent, char: f.char, open: f.open, info: f.info, body });
|
|
26011
|
+
if (built[0].length - f.indent.length - f.info.length !== f.open) return built;
|
|
26012
|
+
const src = s.source.split('\n');
|
|
26013
|
+
return [src[0]].concat(body, [f.closed ? src[1 + f.body.length] : built[built.length - 1]]);
|
|
26014
|
+
}
|
|
26015
|
+
// R23: the session by value in the raw box's shape, the whole fenced block.
|
|
26016
|
+
function diagramPanelSnapshot(s) {
|
|
26017
|
+
const built = diagramFenceLines(s, s.ta.value);
|
|
26018
|
+
return { blockId: s.blockId, range: null, startLine: s.startLine, endLine: s.endLine, source: s.source,
|
|
26019
|
+
value: built.join('\n'), caret: built[0].length + 1 + s.ta.selectionStart };
|
|
26020
|
+
}
|
|
26021
|
+
|
|
26022
|
+
// The panel's language for a block: what isDiagramBlock() reads from the
|
|
26023
|
+
// render, or — only for the Ctrl+S re-entry (`want`, the language of the
|
|
26024
|
+
// panel being reopened) — the fence's info string when the render is not
|
|
26025
|
+
// a diagram. A dot source with a syntax error renders as plain code (the
|
|
26026
|
+
// bake's fallback), and the panel must still open again on it (fix round
|
|
26027
|
+
// 1, I1).
|
|
26028
|
+
const DIAGRAM_INFO_KIND = { dot: 'dot', graphviz: 'dot', mermaid: 'mermaid' };
|
|
26029
|
+
function diagramPanelKindOf(blockEl, want) {
|
|
26030
|
+
const kind = isDiagramBlock(blockEl);
|
|
26031
|
+
if (kind || !want || !codeEditLib || !blockEl || blockEl.getAttribute('data-block-type') !== 'code') return kind;
|
|
26032
|
+
const rec = blockRecOf(blockEl);
|
|
26033
|
+
if (!rec || rec.endLine < rec.startLine) return null;
|
|
26034
|
+
const f = codeEditLib.parseFence(lines.slice(rec.startLine - 1, rec.endLine));
|
|
26035
|
+
const word = f ? f.info.split(/\s+/)[0].toLowerCase() : '';
|
|
26036
|
+
return DIAGRAM_INFO_KIND[word] === want ? want : null;
|
|
26037
|
+
}
|
|
26038
|
+
|
|
26039
|
+
// Opens the panel on a Graphviz or Mermaid block. false when it is not one
|
|
26040
|
+
// (or has no fenced source); true otherwise, including when another session
|
|
26041
|
+
// could not be resolved (it stays open with its banner) or the block went
|
|
26042
|
+
// away meanwhile (notice). `opts.seed` ({ value, selStart, selEnd,
|
|
26043
|
+
// previewHtml }) is the Ctrl+S re-entry: the text the old panel held, which
|
|
26044
|
+
// may be ahead of the file; `opts.kind` is that panel's language.
|
|
26045
|
+
async function openDiagramPanel(blockEl, opts) {
|
|
26046
|
+
const o = opts || {};
|
|
26047
|
+
let kind = diagramPanelKindOf(blockEl, o.kind);
|
|
26048
|
+
if (!kind || !codeEditLib) return false;
|
|
26049
|
+
// openRawEditor()'s Task G guard: every other session is resolved BEFORE
|
|
26050
|
+
// this one registers, so the focusout that focusing the source raises
|
|
26051
|
+
// finds nothing to resolve. The 編輯圖表 press keeps focus where it was,
|
|
26052
|
+
// so focus is taken off first: a surface that keeps focus can open its
|
|
26053
|
+
// burst again after the resolution (measured: the empty transient a "/"
|
|
26054
|
+
// insert leaves below the diagram), and its focusout on ta.focus() below
|
|
26055
|
+
// then resolved the brand-new, untouched panel — closed in the gesture
|
|
26056
|
+
// that opened it. The blur's own focusout starts that resolution now,
|
|
26057
|
+
// and the switchAwayFrom() below joins it.
|
|
26058
|
+
const ae = document.activeElement;
|
|
26059
|
+
if (ae && ae !== document.body && !blockEl.contains(ae) && typeof ae.blur === 'function') ae.blur();
|
|
26060
|
+
if (switching || (activeEditor && activeEditor.blockEl !== blockEl) ||
|
|
26061
|
+
(currentBurst && currentBurst.blockEl !== blockEl)) {
|
|
26062
|
+
const ok = await switchAwayFrom();
|
|
26063
|
+
if (!ok) return true;
|
|
26064
|
+
if (!blockEl.isConnected) { showError(DROPPED_GESTURE_MESSAGE, null, null); return true; }
|
|
26065
|
+
kind = diagramPanelKindOf(blockEl, o.kind);
|
|
26066
|
+
if (!kind) return false;
|
|
26067
|
+
}
|
|
26068
|
+
const rec = blockRecOf(blockEl);
|
|
26069
|
+
if (!rec || rec.endLine < rec.startLine) return false;
|
|
26070
|
+
const source = lines.slice(rec.startLine - 1, rec.endLine).join('\n');
|
|
26071
|
+
const fence = codeEditLib.parseFence(source.split('\n'));
|
|
26072
|
+
if (!fence) return false;
|
|
26073
|
+
const body = fence.body.join('\n');
|
|
26074
|
+
|
|
26075
|
+
const panel = document.createElement('div');
|
|
26076
|
+
panel.className = 'ed-diagram-panel';
|
|
26077
|
+
const head = document.createElement('div');
|
|
26078
|
+
head.className = 'ed-diagram-head';
|
|
26079
|
+
const langEl = document.createElement('span');
|
|
26080
|
+
langEl.className = 'ed-diagram-lang';
|
|
26081
|
+
langEl.textContent = DIAGRAM_LANG_LABEL[kind];
|
|
26082
|
+
const hint = document.createElement('span');
|
|
26083
|
+
hint.className = 'ed-diagram-hint';
|
|
26084
|
+
hint.textContent = '預覽在停止輸入 0.5 秒後更新';
|
|
26085
|
+
const done = document.createElement('button');
|
|
26086
|
+
done.type = 'button';
|
|
26087
|
+
done.className = 'ed-diagram-done';
|
|
26088
|
+
done.textContent = '完成';
|
|
26089
|
+
head.appendChild(langEl);
|
|
26090
|
+
head.appendChild(hint);
|
|
26091
|
+
head.appendChild(done);
|
|
26092
|
+
const ta = document.createElement('textarea');
|
|
26093
|
+
ta.className = 'ed-diagram-src';
|
|
26094
|
+
ta.spellcheck = false;
|
|
26095
|
+
ta.setAttribute('aria-label', DIAGRAM_LANG_LABEL[kind] + ' 原始碼');
|
|
26096
|
+
ta.value = o.seed ? o.seed.value : body;
|
|
26097
|
+
const preview = document.createElement('div');
|
|
26098
|
+
preview.className = 'ed-diagram-preview';
|
|
26099
|
+
preview.tabIndex = -1; // R36: a press on it may take focus, inside the panel
|
|
26100
|
+
if (o.seed && o.seed.previewHtml) preview.insertAdjacentHTML('beforeend', o.seed.previewHtml);
|
|
26101
|
+
const msg = document.createElement('div');
|
|
26102
|
+
msg.className = 'ed-diagram-error';
|
|
26103
|
+
msg.hidden = true;
|
|
26104
|
+
msg.setAttribute('role', 'status');
|
|
26105
|
+
panel.appendChild(head);
|
|
26106
|
+
panel.appendChild(ta);
|
|
26107
|
+
panel.appendChild(preview);
|
|
26108
|
+
panel.appendChild(msg);
|
|
26109
|
+
|
|
26110
|
+
const s = {
|
|
26111
|
+
kind, blockEl, panel, ta, preview, msg, fence, body, source,
|
|
26112
|
+
blockId: rec.id, startLine: rec.startLine, endLine: rec.endLine,
|
|
26113
|
+
originalHtml: blockEl.innerHTML, darkAtOpen: diagramThemeIsDark(),
|
|
26114
|
+
timer: null, previewSeq: 0, inflight: null, lastResult: undefined, reentering: null, pressInside: false,
|
|
26115
|
+
listeners: new AbortController(), offTheme: null, editor: null,
|
|
26116
|
+
};
|
|
26117
|
+
s.editor = {
|
|
26118
|
+
blockEl,
|
|
26119
|
+
hasChanges: () => s.ta.value !== s.body,
|
|
26120
|
+
snapshot: () => diagramPanelSnapshot(s),
|
|
26121
|
+
commitNow: () => commitDiagramPanel(s),
|
|
26122
|
+
cancelNow: () => cancelDiagramPanel(s),
|
|
26123
|
+
reanchor: () => reanchorDiagramPanel(s),
|
|
26124
|
+
};
|
|
26125
|
+
activeEditor = s.editor;
|
|
26126
|
+
// Fix round 1 (M3): a panel a render other than its own ended (its
|
|
26127
|
+
// commit was superseded) is never read through liveDiagramPanel() again
|
|
26128
|
+
// once this one takes the slot; its theme subscription goes now.
|
|
26129
|
+
if (diagramPanelState && diagramPanelState !== s) disposeDiagramPanel(diagramPanelState);
|
|
26130
|
+
diagramPanelState = s;
|
|
26131
|
+
hideDiagramEditBtn();
|
|
26132
|
+
// Detach census: nothing in a rendered diagram holds focus (the button's
|
|
26133
|
+
// press keeps it where it was), so this write detaches no focused node.
|
|
26134
|
+
blockEl.replaceChildren(panel);
|
|
26135
|
+
autoSize(ta);
|
|
26136
|
+
ta.focus({ preventScroll: true });
|
|
26137
|
+
if (o.seed) ta.setSelectionRange(o.seed.selStart, o.seed.selEnd);
|
|
26138
|
+
else ta.setSelectionRange(0, 0);
|
|
26139
|
+
|
|
26140
|
+
const on = { signal: s.listeners.signal };
|
|
26141
|
+
ta.addEventListener('input', () => { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }, on);
|
|
26142
|
+
panel.addEventListener('keydown', (e) => onDiagramPanelKeydown(s, e), on);
|
|
26143
|
+
// R36: a press inside never ends the panel. On anything but the source
|
|
26144
|
+
// and the preview it keeps focus where it is; a focusout with no
|
|
26145
|
+
// relatedTarget right after a press inside is that press, not a leave.
|
|
26146
|
+
panel.addEventListener('mousedown', (e) => {
|
|
26147
|
+
s.pressInside = true;
|
|
26148
|
+
setTimeout(() => { s.pressInside = false; }, 0);
|
|
26149
|
+
const t = e.target;
|
|
26150
|
+
if (t === ta || (t && t.closest && t.closest('.ed-diagram-preview'))) return;
|
|
26151
|
+
e.preventDefault();
|
|
26152
|
+
}, on);
|
|
26153
|
+
panel.addEventListener('focusout', (e) => {
|
|
26154
|
+
if (liveDiagramPanel() !== s) return;
|
|
26155
|
+
if (e.relatedTarget && panel.contains(e.relatedTarget)) return;
|
|
26156
|
+
if (!e.relatedTarget && s.pressInside) return;
|
|
26157
|
+
switchAwayFrom();
|
|
26158
|
+
}, on);
|
|
26159
|
+
done.addEventListener('click', () => {
|
|
26160
|
+
if (liveDiagramPanel() === s && !s.reentering) commitDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26161
|
+
}, on);
|
|
26162
|
+
const theme = window.md2docTheme;
|
|
26163
|
+
if (theme && typeof theme.onTheme === 'function') {
|
|
26164
|
+
s.offTheme = theme.onTheme((dark) => {
|
|
26165
|
+
if (liveDiagramPanel() !== s) return;
|
|
26166
|
+
if (s.kind === 'dot') {
|
|
26167
|
+
const svg = s.preview.querySelector('svg');
|
|
26168
|
+
if (svg) theme.recolourSvg(svg, dark);
|
|
26169
|
+
return;
|
|
26170
|
+
}
|
|
26171
|
+
// Mermaid's colours are its config: drawn again in the theme now on
|
|
26172
|
+
// screen (renderMermaid() reads it when its turn comes).
|
|
26173
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
26174
|
+
runDiagramPreview(s);
|
|
26175
|
+
});
|
|
26176
|
+
}
|
|
26177
|
+
if (o.seed) setDirty();
|
|
26178
|
+
runDiagramPreview(s);
|
|
26179
|
+
return true;
|
|
26180
|
+
}
|
|
26181
|
+
|
|
26182
|
+
// Keys of the panel's own (R25: the document keydown leaves the rest of
|
|
26183
|
+
// them alone). Esc discards, Ctrl/Cmd+Enter commits, Ctrl/Cmd+S commits,
|
|
26184
|
+
// saves and reopens, Tab in the source inserts two spaces (R36). While a
|
|
26185
|
+
// Ctrl+S is out, Esc / Ctrl+Enter / a second Ctrl+S are kept for the panel
|
|
26186
|
+
// it reopens.
|
|
26187
|
+
function onDiagramPanelKeydown(s, e) {
|
|
26188
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
26189
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
26190
|
+
let act = null;
|
|
26191
|
+
if (e.key === 'Escape' && !mod && !e.altKey) act = 'cancel';
|
|
26192
|
+
else if (e.key === 'Enter' && mod) act = 'commit';
|
|
26193
|
+
else if (mod && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) act = 'save';
|
|
26194
|
+
else if (e.key === 'Tab' && !mod && !e.altKey && !e.shiftKey && e.target === s.ta) act = 'tab';
|
|
26195
|
+
if (!act) return;
|
|
26196
|
+
e.preventDefault();
|
|
26197
|
+
e.stopPropagation();
|
|
26198
|
+
if (act === 'tab') { insertIntoDiagramSource(s.ta, ' '); return; }
|
|
26199
|
+
if (s.reentering) { s.reentering[act] = true; return; }
|
|
26200
|
+
if (s.inflight || liveDiagramPanel() !== s) return; // a commit is out: it ends the panel, or leaves it open on failure
|
|
26201
|
+
if (act === 'cancel') cancelDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26202
|
+
else if (act === 'commit') commitDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26203
|
+
else commitDiagramPanelAndReenter(s, { save: true }).catch(showUnexpectedFailure);
|
|
26204
|
+
}
|
|
26205
|
+
// Through the browser's own insertText, so the textarea's native undo
|
|
26206
|
+
// (Ctrl+Z inside it) takes the two spaces back too.
|
|
26207
|
+
function insertIntoDiagramSource(ta, text) {
|
|
26208
|
+
let ok = false;
|
|
26209
|
+
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
|
|
26210
|
+
if (!ok) {
|
|
26211
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
|
|
26212
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
26213
|
+
}
|
|
26214
|
+
}
|
|
26215
|
+
|
|
26216
|
+
function scheduleDiagramPreview(s) {
|
|
26217
|
+
if (s.timer) clearTimeout(s.timer);
|
|
26218
|
+
s.timer = setTimeout(() => { s.timer = null; runDiagramPreview(s); }, DIAGRAM_PREVIEW_MS);
|
|
26219
|
+
}
|
|
26220
|
+
async function runDiagramPreview(s) {
|
|
26221
|
+
if (liveDiagramPanel() !== s) return;
|
|
26222
|
+
const seq = ++s.previewSeq;
|
|
26223
|
+
const src = s.ta.value;
|
|
26224
|
+
let out;
|
|
26225
|
+
if (!src.trim()) out = { svg: '' };
|
|
26226
|
+
else {
|
|
26227
|
+
try {
|
|
26228
|
+
out = s.kind === 'dot' ? await fetchDotPreview(src) : await renderMermaidPreview(src);
|
|
26229
|
+
} catch (e) {
|
|
26230
|
+
out = { error: DIAGRAM_PREVIEW_FAILED };
|
|
26231
|
+
}
|
|
26232
|
+
}
|
|
26233
|
+
// Newest request wins: an answer to an older one is dropped.
|
|
26234
|
+
if (seq !== s.previewSeq || liveDiagramPanel() !== s) return;
|
|
26235
|
+
showDiagramPreview(s, out);
|
|
26236
|
+
}
|
|
26237
|
+
// R31: 200 { svg } is a drawing (an empty one is nothing to show), 422 is
|
|
26238
|
+
// the engine's first line, anything else the generic line.
|
|
26239
|
+
async function fetchDotPreview(src) {
|
|
26240
|
+
let res;
|
|
26241
|
+
try {
|
|
26242
|
+
res = await fetch('/api/preview', {
|
|
26243
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
26244
|
+
body: JSON.stringify({ fileId: ED.fileId, lang: 'dot', source: src }),
|
|
26245
|
+
});
|
|
26246
|
+
} catch (e) {
|
|
26247
|
+
return { error: DIAGRAM_PREVIEW_FAILED };
|
|
26248
|
+
}
|
|
26249
|
+
let j = null;
|
|
26250
|
+
try { j = await res.json(); } catch (e) { j = null; }
|
|
26251
|
+
if (res.status === 200 && j && typeof j.svg === 'string') return { svg: j.svg };
|
|
26252
|
+
if (res.status === 422 && j && firstLineOf(j.error)) return { error: firstLineOf(j.error) };
|
|
26253
|
+
return { error: DIAGRAM_PREVIEW_FAILED };
|
|
26254
|
+
}
|
|
26255
|
+
async function renderMermaidPreview(src) {
|
|
26256
|
+
if (typeof window.mermaid === 'undefined') {
|
|
26257
|
+
if (diagramEngineGaveUp['mermaid.js']) return { error: DIAGRAM_NO_MERMAID };
|
|
26258
|
+
try { await loadDiagramEngine('mermaid.js'); } catch (e) { return { error: DIAGRAM_NO_MERMAID }; }
|
|
26259
|
+
}
|
|
26260
|
+
const theme = window.md2docTheme;
|
|
26261
|
+
if (!theme || typeof theme.renderMermaid !== 'function') return { error: DIAGRAM_NO_MERMAID };
|
|
26262
|
+
try {
|
|
26263
|
+
return { svg: await theme.renderMermaid(src) };
|
|
26264
|
+
} catch (e) {
|
|
26265
|
+
return { error: firstLineOf(e && e.message !== undefined ? e.message : e) || DIAGRAM_PREVIEW_FAILED };
|
|
26266
|
+
}
|
|
26267
|
+
}
|
|
26268
|
+
function setDiagramMessage(s, kind, text) {
|
|
26269
|
+
if (!kind) {
|
|
26270
|
+
s.msg.hidden = true;
|
|
26271
|
+
s.msg.textContent = '';
|
|
26272
|
+
s.msg.removeAttribute('data-kind');
|
|
26273
|
+
return;
|
|
26274
|
+
}
|
|
26275
|
+
s.msg.hidden = false;
|
|
26276
|
+
s.msg.setAttribute('data-kind', kind);
|
|
26277
|
+
s.msg.textContent = text;
|
|
26278
|
+
}
|
|
26279
|
+
function showDiagramPreview(s, out) {
|
|
26280
|
+
const pv = s.preview;
|
|
26281
|
+
if (out.svg) {
|
|
26282
|
+
// Detach census (d): the preview's own children; the preview itself
|
|
26283
|
+
// (which can hold focus, R36) stays in the document.
|
|
26284
|
+
pv.innerHTML = out.svg;
|
|
26285
|
+
pv.classList.remove('is-stale');
|
|
26286
|
+
setDiagramMessage(s, null);
|
|
26287
|
+
const svg = pv.querySelector('svg');
|
|
26288
|
+
const theme = window.md2docTheme;
|
|
26289
|
+
if (svg && theme && diagramThemeIsDark()) {
|
|
26290
|
+
// A Mermaid render in the dark config is a live one (drawDark()'s
|
|
26291
|
+
// mark): only its author-styled shapes and labels are recoloured.
|
|
26292
|
+
if (s.kind === 'mermaid') svg.setAttribute('data-md2doc-live', '');
|
|
26293
|
+
theme.recolourSvg(svg, true);
|
|
26294
|
+
}
|
|
26295
|
+
return;
|
|
26296
|
+
}
|
|
26297
|
+
if (pv.querySelector('svg')) pv.classList.add('is-stale');
|
|
26298
|
+
if (out.error) setDiagramMessage(s, 'error', out.error);
|
|
26299
|
+
else setDiagramMessage(s, 'empty', DIAGRAM_PREVIEW_EMPTY);
|
|
26300
|
+
}
|
|
26301
|
+
|
|
26302
|
+
function runDiagramPanelCommit(s) {
|
|
26303
|
+
if (!s.inflight) {
|
|
26304
|
+
s.inflight = commitDiagramPanelBody(s).then((r) => { s.lastResult = r; return r; })
|
|
26305
|
+
.finally(() => { s.inflight = null; });
|
|
26306
|
+
}
|
|
26307
|
+
return s.inflight;
|
|
26308
|
+
}
|
|
26309
|
+
async function commitDiagramPanel(s) {
|
|
26310
|
+
return (await runDiagramPanelCommit(s)) !== false;
|
|
26311
|
+
}
|
|
26312
|
+
// true, false (the render failed: rolled back, the panel stays open on its
|
|
26313
|
+
// text, the banner is up) or RENDER_SUPERSEDED.
|
|
26314
|
+
async function commitDiagramPanelBody(s) {
|
|
26315
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
26316
|
+
const text = s.ta.value;
|
|
26317
|
+
// Unchanged first, like the code edit (R49 M1): leaving an untouched
|
|
26318
|
+
// panel writes nothing and never starts a reconciliation.
|
|
26319
|
+
if (text === s.body) return await cancelDiagramPanel(s);
|
|
26320
|
+
if (reconcileBlocksCommits()) {
|
|
26321
|
+
if (!reconcilePromise) startReconcileRender();
|
|
26322
|
+
return false;
|
|
26323
|
+
}
|
|
26324
|
+
const r = await commitFencedRangeAndRender(s.startLine, s.endLine, diagramFenceLines(s, text).join('\n'));
|
|
26325
|
+
if (r === null) return await cancelDiagramPanel(s);
|
|
26326
|
+
if (r === false || r === RENDER_SUPERSEDED) return r;
|
|
26327
|
+
endDiagramPanelVisual(s);
|
|
26328
|
+
return true;
|
|
26329
|
+
}
|
|
26330
|
+
// Esc and the cancel path: nothing written. An untouched pristine insert
|
|
26331
|
+
// goes away again, as the raw box's cancelAndMaybeDiscard() does.
|
|
26332
|
+
//
|
|
26333
|
+
// Final review I2: what Esc throws away is parked in `discardedRawEdit`,
|
|
26334
|
+
// as the raw box's Esc parks its text, so the next Ctrl+Z reopens the block
|
|
26335
|
+
// with it (a raw box over the whole fenced block: the snapshot is in that
|
|
26336
|
+
// shape, R23). Measured before: type in the panel, Esc, Ctrl+Z, and the
|
|
26337
|
+
// typing was gone for good. Not for a pristine insert (the block itself
|
|
26338
|
+
// goes) or a panel with nothing typed.
|
|
26339
|
+
async function cancelDiagramPanel(s) {
|
|
26340
|
+
const wasPristine = !!(pristineInsert && pristineInsert.blockId === s.blockId);
|
|
26341
|
+
if (wasPristine) pristineInsert = null;
|
|
26342
|
+
if (!wasPristine && s.ta.value !== s.body) {
|
|
26343
|
+
dropDiscardedBurst();
|
|
26344
|
+
discardedRawEdit = diagramPanelSnapshot(s);
|
|
26345
|
+
}
|
|
26346
|
+
endDiagramPanelVisual(s);
|
|
26347
|
+
if (wasPristine) return await discardPristineInsert();
|
|
26348
|
+
return true;
|
|
26349
|
+
}
|
|
26350
|
+
// openRawEditor()'s reanchor(), for a panel a patch render kept on screen;
|
|
26351
|
+
// otherwise the panel ends on screen as well (R27 (7)).
|
|
26352
|
+
function reanchorDiagramPanel(s) {
|
|
26353
|
+
const raw = s.blockEl.getAttribute('data-block-id');
|
|
26354
|
+
const b = raw === null ? null : blocks.find((x) => x.id === Number(raw));
|
|
26355
|
+
if (b && b.endLine >= b.startLine && lines.slice(b.startLine - 1, b.endLine).join('\n') === s.source) {
|
|
26356
|
+
s.blockId = b.id;
|
|
26357
|
+
s.startLine = b.startLine;
|
|
26358
|
+
s.endLine = b.endLine;
|
|
26359
|
+
return true;
|
|
26360
|
+
}
|
|
26361
|
+
endDiagramPanelVisual(s);
|
|
26362
|
+
return false;
|
|
26363
|
+
}
|
|
26364
|
+
// Ends the panel on screen. `activeEditor` is cleared FIRST (R36, R27 (5)):
|
|
26365
|
+
// putting the rendered HTML back detaches the focused source, and the
|
|
26366
|
+
// focusout that raises must find no session to resolve. A block still on
|
|
26367
|
+
// screen gets the HTML it had at the open back, drawn for the theme on
|
|
26368
|
+
// screen now.
|
|
26369
|
+
function endDiagramPanelVisual(s) {
|
|
26370
|
+
if (activeEditor === s.editor) activeEditor = null;
|
|
26371
|
+
if (diagramPanelState === s) diagramPanelState = null;
|
|
26372
|
+
disposeDiagramPanel(s);
|
|
26373
|
+
if (s.panel.isConnected) {
|
|
26374
|
+
s.blockEl.innerHTML = s.originalHtml;
|
|
26375
|
+
redrawRestoredDiagram(s);
|
|
26376
|
+
}
|
|
26377
|
+
setDirty(); // R47: a panel left unchanged reads clean again
|
|
26378
|
+
}
|
|
26379
|
+
// The restored HTML is a copy: a Mermaid host is a new node the theme
|
|
26380
|
+
// runtime does not know (adopted and drawn again in the theme on screen),
|
|
26381
|
+
// and a Graphviz svg holds the colours of the theme it was opened in.
|
|
26382
|
+
function redrawRestoredDiagram(s) {
|
|
26383
|
+
if (s.kind === 'mermaid') {
|
|
26384
|
+
if (typeof window.__md2docThemeMermaid === 'function') {
|
|
26385
|
+
try { window.__md2docThemeMermaid(); } catch (e) { /* the block keeps the markup it had */ }
|
|
26386
|
+
}
|
|
26387
|
+
return;
|
|
26388
|
+
}
|
|
26389
|
+
const theme = window.md2docTheme;
|
|
26390
|
+
const svg = s.blockEl.querySelector('.graphviz svg');
|
|
26391
|
+
if (svg && theme && diagramThemeIsDark() !== s.darkAtOpen) theme.recolourSvg(svg, diagramThemeIsDark());
|
|
26392
|
+
}
|
|
26393
|
+
|
|
26394
|
+
// R48 / R56 (c): commit through switchAwayFrom() (the gate a click
|
|
26395
|
+
// elsewhere uses), open the panel again on the block at the same start line
|
|
26396
|
+
// with the text and selection the old one held — what was typed into it
|
|
26397
|
+
// while the render was out included — then save. Keys that landed elsewhere
|
|
26398
|
+
// meanwhile (focus on BODY once the render took the old panel away) are
|
|
26399
|
+
// held by the code edit's hold and replayed into the new one. A failed
|
|
26400
|
+
// render leaves the panel open (R27 (3)); a superseded one leaves no block
|
|
26401
|
+
// to open on, and the held keys are dropped with the notice.
|
|
26402
|
+
async function commitDiagramPanelAndReenter(s, opts) {
|
|
26403
|
+
const ta = s.ta;
|
|
26404
|
+
const startLine = s.startLine;
|
|
26405
|
+
const sent = ta.value; // what this commit writes
|
|
26406
|
+
const flags = { cancel: false, commit: false, save: false };
|
|
26407
|
+
s.reentering = flags;
|
|
26408
|
+
const hold = { keys: [], clickedAway: false, passTarget: ta };
|
|
26409
|
+
codeEditReenterHold = hold;
|
|
26410
|
+
let ok;
|
|
26411
|
+
try {
|
|
26412
|
+
ok = await switchAwayFrom();
|
|
26413
|
+
} finally {
|
|
26414
|
+
if (codeEditReenterHold === hold) codeEditReenterHold = null;
|
|
26415
|
+
s.reentering = null;
|
|
26416
|
+
}
|
|
26417
|
+
// v3.17.0 T9b (T5 M6): the code edit's rules. A press that put the caret
|
|
26418
|
+
// in another text surface meanwhile ends the re-entry (nothing reopens or
|
|
26419
|
+
// takes focus back); a second Ctrl+S held here goes after this one's save.
|
|
26420
|
+
const savesHeld = flags.save || hold.keys.some((k) => k.kind === 'save');
|
|
26421
|
+
let target = liveDiagramPanel() === s ? s : null;
|
|
26422
|
+
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
|
|
26423
|
+
const el = blockElAtLine(startLine);
|
|
26424
|
+
const seed = { value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd,
|
|
26425
|
+
previewHtml: s.preview.innerHTML };
|
|
26426
|
+
if (el && diagramPanelKindOf(el, s.kind) && await openDiagramPanel(el, { seed, kind: s.kind })) target = liveDiagramPanel();
|
|
26427
|
+
}
|
|
26428
|
+
if (ok && opts.save) saveAfterLastSave();
|
|
26429
|
+
if (!target || hold.clickedAway) {
|
|
26430
|
+
// Nowhere to reopen (a superseded render, a block gone, a press
|
|
26431
|
+
// elsewhere): what was typed after the commit took its text, into the
|
|
26432
|
+
// old source or elsewhere, is dropped — said, never silent (fix round
|
|
26433
|
+
// 1, I1). A panel still open (its render failed) keeps its own text.
|
|
26434
|
+
if (hold.keys.some((k) => k.kind !== 'save') || (!target && ta.value !== sent)) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
26435
|
+
if (ok && savesHeld) saveAfterLastSave();
|
|
26436
|
+
return !!ok;
|
|
26437
|
+
}
|
|
26438
|
+
const leave = replayIntoDiagramPanel(target, hold.keys);
|
|
26439
|
+
let end = null;
|
|
26440
|
+
if (flags.cancel || leave === 'cancel') end = cancelDiagramPanel(target);
|
|
26441
|
+
else if (flags.commit || leave === 'commit') end = commitDiagramPanel(target);
|
|
26442
|
+
if (end) {
|
|
26443
|
+
if (savesHeld) end.then((landed) => { if (landed) saveAfterLastSave(); }, () => {});
|
|
26444
|
+
return end;
|
|
26445
|
+
}
|
|
26446
|
+
if (savesHeld) return commitDiagramPanelAndReenter(target, { save: true });
|
|
26447
|
+
return !!ok;
|
|
26448
|
+
}
|
|
26449
|
+
// Text, Enter, Tab and Backspace held while the old panel was gone go into
|
|
26450
|
+
// the new one's source, in order; Esc (discard) or Ctrl+Enter (commit)
|
|
26451
|
+
// leaves after them; a held Ctrl+S is the caller's (`savesHeld`); anything
|
|
26452
|
+
// else is dropped with the notice.
|
|
26453
|
+
function replayIntoDiagramPanel(s, keys) {
|
|
26454
|
+
const ta = s.ta;
|
|
26455
|
+
let changed = false;
|
|
26456
|
+
let dropped = false;
|
|
26457
|
+
let leave = null;
|
|
26458
|
+
for (const k of keys) {
|
|
26459
|
+
if (k.kind === 'save') continue;
|
|
26460
|
+
if (leave) { dropped = true; continue; }
|
|
26461
|
+
if (k.kind === 'leave') { leave = k.esc ? 'cancel' : 'commit'; continue; }
|
|
26462
|
+
const text = k.kind === 'insert' ? k.data : k.kind === 'enter' ? '\n' : k.kind === 'tab' ? ' ' : null;
|
|
26463
|
+
if (text !== null) {
|
|
26464
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
|
|
26465
|
+
changed = true;
|
|
26466
|
+
} else if (k.kind === 'backspace') {
|
|
26467
|
+
const a = ta.selectionStart;
|
|
26468
|
+
const b = ta.selectionEnd;
|
|
26469
|
+
ta.setRangeText('', a === b ? Math.max(0, a - 1) : a, b, 'end');
|
|
26470
|
+
changed = true;
|
|
26471
|
+
} else {
|
|
26472
|
+
dropped = true;
|
|
26473
|
+
}
|
|
26474
|
+
}
|
|
26475
|
+
if (changed) { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }
|
|
26476
|
+
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
26477
|
+
return leave;
|
|
26478
|
+
}
|
|
26479
|
+
|
|
26480
|
+
// v3.17.0 T5: a press on the theme toggle never takes focus from an edit,
|
|
26481
|
+
// so switching the theme does not end it (the click router ignores the
|
|
26482
|
+
// toggle's click as well).
|
|
26483
|
+
document.addEventListener('mousedown', (e) => {
|
|
26484
|
+
if (e.button === 0 && e.target && e.target.closest && e.target.closest('.md2doc-theme-toggle')) e.preventDefault();
|
|
26485
|
+
}, true);
|
|
26486
|
+
|
|
26487
|
+
// The button's gesture: whatever else is open (a paragraph typed in a
|
|
26488
|
+
// moment ago) is resolved first, and its render may replace this block. It
|
|
26489
|
+
// is found again by content, only when it is the one block with that source
|
|
26490
|
+
// (R104, as the click router does), else the gesture is dropped with the
|
|
26491
|
+
// notice.
|
|
26492
|
+
// `opts.reselect` (Enter on a selected block, R60 (3)): when the panel does
|
|
26493
|
+
// not open after all (openDiagramPanel() false: the block is no Graphviz /
|
|
26494
|
+
// Mermaid block with a fenced source by then), the block is selected again.
|
|
26495
|
+
async function openDiagramPanelFromGesture(blk, opts) {
|
|
26496
|
+
const epochAtPress = reconcileEpoch;
|
|
26497
|
+
const identity = commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blk) : null;
|
|
26498
|
+
if (!(await switchAwayFrom())) return;
|
|
26499
|
+
await settleCommits();
|
|
26500
|
+
const live = refindBlock(blk, identity);
|
|
26501
|
+
if (!live || reconcileEpoch !== epochAtPress || (reconcileBlocksCommits() && !blockReadsAsDrawn(live))) {
|
|
26502
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
26503
|
+
return;
|
|
26504
|
+
}
|
|
26505
|
+
const opened = await openDiagramPanel(live);
|
|
26506
|
+
if (opened === false && opts && opts.reselect && live.isConnected && !blockSelection && !activeEditor) {
|
|
26507
|
+
const rec = blockRecOf(live);
|
|
26508
|
+
if (rec) setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
|
|
26509
|
+
}
|
|
26510
|
+
}
|
|
26511
|
+
|
|
24159
26512
|
// The fence BODY of one block: its source lines with the opening fence, and
|
|
24160
26513
|
// the closing fence when there is one, taken off. An unterminated fence at the
|
|
24161
26514
|
// end of the document has no closing line to drop, and dropping the last line
|