@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.
@@ -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
- return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 reresolveBlockEl()'s source fingerprint is guaranteed to
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
- // reresolveBlockEl() keeps its fingerprint for everybody else.
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 = reresolveBlockEl(identity) ||
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 / reresolveBlockEl ............... skip (null)
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 = reresolveBlockEl(identity) ||
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.isConnected ? blockEl
7492
- : (identity ? (reresolveBlockEl(identity) || reresolveBlockElAfterSelfCommit(identity)) : null);
7493
- if (!live) { showNotice(TOOLBAR_NO_ANCHOR_MESSAGE); return; }
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
- // The caret right after the opening fence: a language typed there is
7829
- // the info string, and Enter moves on to the body (R22).
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
- async function resolveGutterOperands(blockEl, anchor) {
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 reresolveBlockEl()'s SOURCE fingerprint is
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 = reresolveBlockEl(identity) ||
9114
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
9115
- if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return null; }
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
- if (blockEl) openRawEditor(blockEl).catch(() => {});
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. Deliberately NOT folded into reresolveBlockEl() — every
9627
- // other caller of that function needs the fingerprint, and a shared helper
9628
- // that sometimes skips it is the shape that lets a future call site act on a
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, see reresolveBlockEl()
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
- dropDiscardedBurst();
11047
- // v3.4.0 (Task 7 item 5): opening ANY new session — burst or raw —
11048
- // invalidates a still-parked raw-edit discard from a different block; see
11049
- // discardedRawEdit's own comment for why the two stashes must stay
11050
- // mutually exclusive.
11051
- dropDiscardedRawEdit();
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
- if (burst.blockType === 'paragraph' && transientSurfaceEmpty(burst.blockEl)) {
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) createTransient(anchor ? subtreeEndOf(anchor) : null, { focus: true });
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
- return !blockEl.querySelector('.ed-raw');
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() / reresolveBlockEl() is the handle, for the reason
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 the start line plus the block's
14460
- // own source lines is what proves the block sitting there afterwards is the
14461
- // one the press was aimed at. When it cannot be proven nothing is focused —
14462
- // the conservative half of the trade the gutter gestures already take.
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
- const liveBlockEl = document.body.contains(destBlockEl)
14499
- ? destBlockEl : reresolveBlockEl(identity);
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)) || reresolveBlockEl(destIdentity);
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
- // reresolveBlockEl() then proves the block sitting at that start line
17974
- // afterwards really is the same one, exactly as the source's own re-resolve
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
- // reresolveBlockEl()'s fingerprint is the block's SOURCE, and the commit
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 reresolveBlockEl() keeps its fingerprint for
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 = reresolveBlockEl(destIdentity) ||
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
- linkFieldState = { el, input, root, range, existing: opts.existing || null };
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 === blockEl && (e.clientX || e.clientY)) {
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
- const epochAtPress = press ? press.epoch : reconcileEpoch;
20219
- const identity = press ? press.identity
20220
- : (commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null);
20221
- const ok = await switchAwayFrom();
20222
- if (!ok) return;
20223
- await settleCommits();
20224
- // R104 (final re-review 3, I-R2): found again by content only when it is
20225
- // the one block of its type with that source — a start line is no proof
20226
- // (R95's removal of an emptied paragraph shifts lines unlogged, and an
20227
- // identical quote then sat on the clicked one's line and was opened).
20228
- const live = blockEl.isConnected ? blockEl : uniqueBlockElBySource(identity);
20229
- if (!live || reconcileEpoch !== epochAtPress ||
20230
- (reconcileBlocksCommits() && !blockReadsAsDrawn(live))) {
20231
- showError(DROPPED_GESTURE_MESSAGE, null, null);
20232
- return;
20233
- }
20234
- openRawEditor(live);
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
- try { return await sendSave(); } finally { savesInFlight--; }
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
- // reresolveBlockEl + reresolveBlockElAfterSelfCommit) — that function solved
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 reresolveBlockEl()'s fingerprint is guaranteed to
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 && document.body.contains(blockEl))
21716
- ? blockEl
21717
- : (reresolveBlockEl(identity) ||
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 = document.body.contains(anchorEl)
21733
- ? anchorEl : reresolveBlockEl(dialogIdentity);
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 = true;
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
- openRawViaGutter(from);
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): so does the URL field — back to the toolbar over the
23251
- // selection, which the rest of this function then repositions.
23252
- if (linkFieldState) closeLinkField({ restore: true });
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