@helping-ai-workflow/md2doc 3.3.0 → 3.4.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.
@@ -506,8 +506,147 @@
506
506
  return rest === '' ? '#'.repeat(newDepth) : '#'.repeat(newDepth) + ' ' + rest;
507
507
  }
508
508
 
509
+ // v3.4.0 batch2 Task 6 fix round 1 (review F6) — which page of a re-baked
510
+ // multi-page drawio box the reader should land on, and whether we are
511
+ // entitled to tell them their page is gone.
512
+ //
513
+ // Page names are NOT identities. lib/drawio.js's pageNamesOf() pushes '' for
514
+ // any <diagram> with no name attribute (normal in hand-written and
515
+ // tool-exported files) and nothing de-duplicates the list, so the old
516
+ // `newNames.indexOf(keepName)` had two opposite failure modes on perfectly
517
+ // ordinary input: an all-unnamed file always resolved to index 0, silently
518
+ // moving the reader back to page 1 with no banner; and a `keepName` of null
519
+ // (nothing identifiable was open) resolved to -1, raising a banner claiming
520
+ // a deletion that never happened. A false banner is worse than no banner —
521
+ // it is what teaches a user to ignore the true one.
522
+ //
523
+ // So: use the name only where the name IS an identity (non-empty AND unique
524
+ // in the old list), fall back to position otherwise, and claim deletion only
525
+ // where it is actually established — a once-unique name that is now absent,
526
+ // or a position the document no longer reaches.
527
+ //
528
+ // `gone: false` with a moved `landOn` is not silence: the caller lands the
529
+ // reader on a page that is genuinely the same one (renumbered by a reorder),
530
+ // which is the outcome Ruling B2-P1's "page still there" branch describes.
531
+ function resolveDrawioLanding(oldNames, activeIdx, newNames) {
532
+ const olds = Array.isArray(oldNames) ? oldNames : [];
533
+ const news = Array.isArray(newNames) ? newNames : [];
534
+ if (news.length === 0) return { landOn: 0, gone: false };
535
+ // Nothing identifiable was open (no visible .drawio-page, or an index the
536
+ // old name list never had). We know nothing, so we claim nothing.
537
+ if (!(activeIdx >= 0) || activeIdx >= olds.length) return { landOn: 0, gone: false };
538
+ const name = olds[activeIdx];
539
+ let oldCount = 0;
540
+ for (let i = 0; i < olds.length; i++) if (olds[i] === name) oldCount++;
541
+ if (name !== '' && oldCount === 1) {
542
+ const hits = [];
543
+ for (let i = 0; i < news.length; i++) if (news[i] === name) hits.push(i);
544
+ // Exactly one match: a rename-free reorder, resolved exactly.
545
+ // More than one: the name stopped being an identity between the two
546
+ // bakes — land on the first bearer, but do NOT claim a deletion we
547
+ // cannot establish.
548
+ if (hits.length > 0) return { landOn: hits[0], gone: false };
549
+ return { landOn: 0, gone: true };
550
+ }
551
+ // Empty or duplicated name: position is the only sound identity left.
552
+ if (activeIdx < news.length) return { landOn: activeIdx, gone: false };
553
+ return { landOn: 0, gone: true };
554
+ }
555
+
556
+ // v3.4.0 batch3 Task 7 fix 1 (F2) — the block map after an edit that changed
557
+ // the line count INSIDE one block and nowhere else.
558
+ //
559
+ // This is not `ops.shiftBlocks()` on its own, and the difference is the whole
560
+ // point. `commitRangeEdit()` anchors its shift on "the last block ENDING at
561
+ // or before the edited range", and for an edit to a fenced block's BODY that
562
+ // anchor is the block BEFORE the fence — so its answer moves the fenced
563
+ // block's own `startLine`, which did not move, and leaves its `endLine`
564
+ // where it was, which did. Here the edited block is named outright: it keeps
565
+ // its `startLine`, its `endLine` absorbs the delta, every block after it
566
+ // moves by the delta, and every block before it is untouched.
567
+ //
568
+ // Why it exists at all: the wave editor defers the block map to the render at
569
+ // the end of a session, and a render can FAIL. Before this, a failed close
570
+ // render left `blocks` addressing the pre-session document while `lines`
571
+ // carried the session's delta — MEASURED, the next ordinary paragraph edit
572
+ // then committed over the blank line after the closing fence, eating the
573
+ // separator and duplicating the paragraph, with no warning of any kind. The
574
+ // rule this restores is that no ordinary edit may ever land against a block
575
+ // map the renderer has not confirmed: where the renderer cannot confirm it,
576
+ // the session's own arithmetic settles it instead.
577
+ function shiftBlocksAfterBodyEdit(blocks, blockId, delta) {
578
+ if (delta === 0) return blocks;
579
+ return blocks.map((b) => {
580
+ if (b.id === blockId) return Object.assign({}, b, { endLine: b.endLine + delta });
581
+ if (b.id > blockId) {
582
+ return Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta });
583
+ }
584
+ return b;
585
+ });
586
+ }
587
+
588
+ // v3.4.0 batch3 Task 7 fix 2 (MUST-FIX 1) — may a wave session's own commits
589
+ // simply be popped off the document stack?
590
+ //
591
+ // Two questions, asked separately, each answered by the stack itself rather
592
+ // than by arithmetic over a quantity that can be wrong:
593
+ //
594
+ // `depth` are there still exactly `ops` entries above where this
595
+ // session started? The pop removes that many, so anything
596
+ // else pushing inside the session (see openWaveEditor()'s
597
+ // `switchAwayFrom()` guard) would make it reverse somebody
598
+ // else's edit.
599
+ // ⚠ This proves the COUNT, not the identity. A foreign push
600
+ // paired with a foreign pop inside one session satisfies it —
601
+ // `discardPristineInsert()` is exactly that shape. It is not
602
+ // reachable today for one reason and it is worth naming: the
603
+ // modal returns on every non-save key, and the only path that
604
+ // could open a block editor mid-session is Ctrl+S's
605
+ // `switchAwayFrom()`, which openWaveEditor() now resolves
606
+ // BEFORE the overlay opens. If that guard ever moves, this
607
+ // check has to become identity-based.
608
+ // `savedDepth` would the pop destroy the state disk was written from? It
609
+ // takes the stack from `baseDepth + ops` back to `baseDepth`,
610
+ // so the answer is yes exactly when the save marker sits
611
+ // ABOVE `baseDepth` — i.e. names a state only reachable
612
+ // through this session's own ops. `null` (the marker
613
+ // invalidated because history diverged past it) is safe: it
614
+ // names nothing, and the document reads dirty either way.
615
+ // ⚠ fix 4 (R4): this question is answered with the marker as
616
+ // it stands NOW, and a save that is still in flight has not
617
+ // moved it yet. MEASURED: paint twice, Ctrl+S inside the
618
+ // session, Escape before the reply — this says the pop is
619
+ // safe and pops a drawing the server has already written. It
620
+ // is not a hole, because the receipt closes it from the other
621
+ // end: when that reply lands, `markSaved()` finds the
622
+ // timeline at its boundary gone, invalidates the marker, and
623
+ // the document reads dirty. But this function cannot see it,
624
+ // and the honest reading of its answer is "safe as far as any
625
+ // save that has REPLIED is concerned".
626
+ //
627
+ // Both are POSITIONS compared against the session's own baseline, which is
628
+ // what makes them sign-safe. What they REPLACE: fix 1 derived "no save
629
+ // happened" from `stack.dirtyDepth === baseDirtyDepth + ops`. That
630
+ // implication only runs the way it was used when `baseDirtyDepth >= 0`;
631
+ // algebraically it is equally satisfied when
632
+ // `baseDirtyDepth === -(ops taken before the save)`, and a user reaches a
633
+ // negative baseline by pressing Ctrl+Z once after a save. MEASURED: edit,
634
+ // Ctrl+S, Ctrl+Z, open the editor, paint, Ctrl+S, paint, Escape — the
635
+ // predicate read `1 === -1 + 2`, answered "no save", and popped blind,
636
+ // reproducing the original defect through the fixed code.
637
+ //
638
+ // Deliberately NOT "did the marker move": the marker legitimately goes
639
+ // 1 → null → 1 inside one session (the first wave commit invalidates it, a
640
+ // mid-session Ctrl+S establishes it again), so watching it for change both
641
+ // misses that case and refuses the harmless one where only the invalidation
642
+ // happened.
643
+ function waveDiscardIsSafe(stack, seam) {
644
+ if (stack.depth !== seam.baseDepth + seam.ops) return false;
645
+ return stack.savedDepth === null || stack.savedDepth <= seam.baseDepth;
646
+ }
647
+
509
648
  if (typeof module === 'object' && module.exports) {
510
- module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
649
+ module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
511
650
  return; // node: pure core only
512
651
  }
513
652
 
@@ -532,6 +671,20 @@
532
671
  const docSourceLib = window.md2docDocSource;
533
672
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
534
673
  let lastParts = null;
674
+ // v3.4.0 batch2 Task 6 fix round 1 (review F1) — true while `lastParts` is
675
+ // the bootstrap seed taken off the server-rendered DOM (see just below
676
+ // contentEl) rather than a `parts` array a render actually handed back.
677
+ // The seed is byte-for-byte the DOM's OWN serialization, which is not the
678
+ // same string the server emitted (the HTML parser normalizes attribute
679
+ // quoting, `hidden` becomes `hidden=""`, and so on), so it is fit for the
680
+ // positional/id checks domMatchesLastRender() makes and for "did this
681
+ // block's baked output change at all", but NOT for patchmap(), which
682
+ // diffs the strings themselves. It is also what keeps the documented
683
+ // "the first commit after a page load is ALWAYS a full render" behaviour
684
+ // exactly as it was — test/editor-journey.test.js's primed/unprimed axis
685
+ // is built on that property and would silently lose half its coverage if
686
+ // seeding `lastParts` quietly enabled the patch route one commit earlier.
687
+ let lastPartsIsBootstrapSeed = false;
535
688
  // F10: 上一次【成功套用】的 render 留下來的東西 —— 當時的整份 `lines`,以及
536
689
  // 當時的文件是不是以一道沒閉合的圍欄收尾。
537
690
  //
@@ -552,6 +705,28 @@
552
705
  const stack = new ops.UndoStack();
553
706
  const baseTitle = document.title;
554
707
  const contentEl = document.querySelector('.content');
708
+ // v3.4.0 batch2 Task 6 fix round 1 (review F1, BLOCKING) — establish, at
709
+ // first paint, the knowledge domMatchesLastRender() is a check ON.
710
+ //
711
+ // `lastParts` used to stay null until this tab's FIRST COMMIT, because the
712
+ // bootstrap payload carries no `parts` and the initial DOM is rendered
713
+ // server-side (no client render runs at load). refreshStaleDrawio()'s
714
+ // "does the DOM still match the render I last knew about" guard is
715
+ // therefore unconditionally false on a freshly opened tab — and "open a
716
+ // document, don't edit it, change the .drawio in Draw.io" is the exact
717
+ // story this whole feature exists for. The re-bake was computed, paid for
718
+ // (a headless-Chromium bake) and thrown away, while the server's baseline
719
+ // advanced so the signal never came again.
720
+ //
721
+ // The guard is right; the bootstrap simply never established what it
722
+ // checks. It does now: at this point .content's children ARE the render
723
+ // the server just sent, one element per block, so reading them back is not
724
+ // an approximation of that knowledge — it is that knowledge. Taken here,
725
+ // before armEditables() (called at the very bottom of this IIFE) adds its
726
+ // own attributes, so the seed describes the render rather than the armed
727
+ // document.
728
+ lastParts = Array.prototype.map.call(contentEl.children, (el) => el.outerHTML);
729
+ lastPartsIsBootstrapSeed = true;
555
730
 
556
731
  // Click-to-switch substrate (Phase-2 Task 1; replaces the old "refuse a
557
732
  // second block's editor outright" policy). At most one block editor is
@@ -620,6 +795,90 @@
620
795
  discardedBurst = null;
621
796
  }
622
797
 
798
+ // v3.4.0 (Task 7 item 5) — the raw-editor twin of `discardedBurst` just
799
+ // above. Same one-shot contract (answers exactly one Ctrl+Z, the one right
800
+ // after the Escape/✕ that created it) and the SAME invalidation rule
801
+ // (dropped the moment any new burst/raw session opens, or the page
802
+ // re-renders) — see discardedBurst's own comment for why that window has
803
+ // to be this narrow. It cannot REUSE discardedBurst's mechanism, though:
804
+ // that one parks a live `history` object keyed on the burst's own DOM node,
805
+ // and openRawEditor() tears its OWN surface down on open
806
+ // (`blockEl.innerHTML = ''`) before a textarea ever exists — there is no
807
+ // node to park a history against. So this parks a plain VALUE snapshot of
808
+ // the textarea instead (what was typed, where the caret was, and enough of
809
+ // the session's own addressing — blockId/range/startLine/endLine/source —
810
+ // to re-derive the same block and verify nothing else moved underneath it)
811
+ // and Ctrl+Z's job is to RE-OPEN the raw editor and put that value back —
812
+ // not to commit it. Escape's semantics are unchanged: it still discards,
813
+ // still leaves. What changes is that the discard is no longer permanent.
814
+ //
815
+ // `discardedBurst` and this are mutually exclusive by construction (only
816
+ // one editing session — a WYSIWYG burst or a raw editor — is ever open at
817
+ // once): cancelAndMaybeDiscard() below drops any live `discardedBurst` the
818
+ // moment IT stashes here, and revertBurstAndEnd()/stashDiscardedTableBurst()
819
+ // drop this the moment THEY stash into `discardedBurst` — so undo()'s
820
+ // "check the burst stash, then the raw stash" order never answers a stale
821
+ // one while a fresher discard from the OTHER substrate is sitting unused.
822
+ let discardedRawEdit = null; // { blockId, range, startLine, endLine, source, value, caret } | null
823
+
824
+ function dropDiscardedRawEdit() {
825
+ discardedRawEdit = null;
826
+ }
827
+
828
+ // v3.4.0 batch3 Task 7 — the wave-editor member of the same family as the
829
+ // two stashes above. Escape out of the wave overlay still DISCARDS, and this
830
+ // is what makes the discard recoverable by exactly one document-level
831
+ // Ctrl+Z, the same one-shot contract `discardedBurst` spells out.
832
+ //
833
+ // Which of discardedBurst's rules are reused here, and which cannot be:
834
+ //
835
+ // REUSED — the one-shot window (the only Ctrl+Z it answers is the one
836
+ // right after the Escape that created it), the drop-on-any-render rule
837
+ // (rerenderAll() clears all three at its top), mutual exclusivity with the
838
+ // other two, and a staleness guard that refuses rather than applying a
839
+ // restore over a document that moved underneath it.
840
+ //
841
+ // NOT APPLICABLE — parking a live `history` object keyed on the burst's
842
+ // own DOM node. discardedBurst can do that because the burst's
843
+ // contenteditable survives its own Escape; the wave overlay is mounted on
844
+ // `document.body` and `close()` REMOVES it (and every document listener it
845
+ // installed) before onClose ever runs, so there is no node and no live
846
+ // store left to park. Nor is `editEl`/`burstBaselineHtml()` meaningful:
847
+ // nothing the wave editor produces was ever HTML in `.content`.
848
+ //
849
+ // NOT APPLICABLE — "the burst never committed, so `lines` never saw it".
850
+ // The wave editor writes back PER GESTURE, so by the time Escape is
851
+ // pressed `lines` has seen every one of them and the document stack is
852
+ // carrying them. That inverts what Escape has to do: it is the one stash
853
+ // in this file whose creation REMOVES ops from the stack (see
854
+ // finishWaveSession()) rather than never having put any there.
855
+ //
856
+ // So this parks a plain VALUE pair — the block body as it stood before the
857
+ // session (`source`, which is what `lines` holds again once the Escape has
858
+ // run) and as it stood at the moment of the Escape (`painted`) — plus enough
859
+ // addressing to prove nothing else moved. Ctrl+Z's job is to put `painted`
860
+ // back through an ordinary commit and RE-OPEN the editor over it.
861
+ let discardedWaveEdit = null; // { blockId, blockStartLine, blockEndLine, startLine, endLine, source, painted } | null
862
+
863
+ function dropDiscardedWaveEdit() {
864
+ discardedWaveEdit = null;
865
+ }
866
+
867
+ // v3.4.0 batch3 Task 7 — a wave gesture whose patch was REFUSED, i.e. work
868
+ // the user can see in the overlay that `lines` does not hold and cannot be
869
+ // made to hold by a local patch. Declared up here beside the stashes rather
870
+ // than down with the rest of the wave wiring (~16900) for one reason:
871
+ // documentIsDirty() reads it, documentIsDirty() is reachable from the very
872
+ // first setDirty(), and a `let` declared further down would still be in its
873
+ // temporal dead zone then.
874
+ //
875
+ // This is the second half of the Reload gate. The first half is free —
876
+ // every write-back that SUCCEEDS goes through commitRangeEdit(), so
877
+ // `stack.dirtyDepth` already counts it and the beforeunload guard already
878
+ // covers it. A refusal is the case that guard cannot see, because by
879
+ // definition nothing reached `lines`.
880
+ let waveUnwrittenEdit = false;
881
+
623
882
  // §10-gap fix (review): the block insertBlockBelow() most recently
624
883
  // inserted, tracked from the moment its edit surface is first focused
625
884
  // until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
@@ -772,9 +1031,13 @@
772
1031
  return await activeEditor.commitNow(); // false → editor stays open, banner already shown
773
1032
  }
774
1033
 
775
- // `stack.dirtyDepth` is `_done.length - _savedDepth` (lineops.js), so it
776
- // moves when an op is pushed onto or popped off the undo stack, or when a
777
- // save re-baselines it. The burst substrate keeps what the user typed in
1034
+ // `stack.isDirty()` asks whether the undo stack is standing on the state the
1035
+ // last save wrote (lineops.js). `dirtyDepth` is the same question as a
1036
+ // distance, and only while that distance exists at all: once a push has
1037
+ // diverged past the save marker the marker is invalidated, and the getter
1038
+ // then answers `_done.length + 1` — never 0. Either way the answer moves
1039
+ // when an op is pushed onto or popped off the stack, or when a save
1040
+ // re-baselines it. The burst substrate keeps what the user typed in
778
1041
  // the DOM until the burst resolves, and none of those has happened yet at
779
1042
  // that point — so a block that has been typed into but not yet left reads
780
1043
  // as clean. MEASURED on `# Doc\n\nAlpha paragraph.\n` (click the
@@ -791,9 +1054,35 @@
791
1054
  burstBaselineHtml(currentBurst.editEl) !== currentBurst.original);
792
1055
  }
793
1056
 
1057
+ // v3.4.0 §3: single source of truth for "does the document have anything
1058
+ // unsaved" — setDirty() (the title dot), toolbarContext() (the `save`
1059
+ // button's own lit/grey state), and the beforeunload guard further below
1060
+ // all ask this instead of each carrying its own copy of the expression
1061
+ // (review I1: the beforeunload handler used to be a second copy — found
1062
+ // and fixed). Do not inline this back into any call site; that is exactly
1063
+ // the drift this function exists to prevent.
1064
+ // v3.4.0 batch3 Task 7 adds the third term. MEASURED before it existed, with
1065
+ // the wave editor open over a real paint: `document.title` was 'doc', the
1066
+ // file on disk was byte-identical, and pressing the conflict banner's Reload
1067
+ // raised NO beforeunload dialog at all — because this predicate is about
1068
+ // `lines`, and a waveform had never been in `lines`. Two thirds of that gap
1069
+ // close by themselves the moment write-back goes through commitRangeEdit()
1070
+ // (`stack.dirtyDepth` counts it like any other commit); `waveUnwrittenEdit`
1071
+ // is the remaining third — a gesture the store could not express as a local
1072
+ // patch, which the user can see on screen and `lines` does not hold.
1073
+ function documentIsDirty() {
1074
+ // fix 2: `stack.isDirty()`, not `stack.dirtyDepth !== 0`. MEASURED on the
1075
+ // shipped behaviour, with no wave editor involved: edit, Ctrl+S, Ctrl+Z,
1076
+ // one more ordinary edit — the depth came back to 0 FROM BELOW and this
1077
+ // answered false over a document that differed from disk in both edits.
1078
+ // The distance was a second record of "are we standing where we saved";
1079
+ // `UndoStack` now answers that question itself, and a boolean has no zero
1080
+ // to walk through. See lineops.js's own comment for the full story.
1081
+ return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit;
1082
+ }
1083
+
794
1084
  function setDirty() {
795
- const dirty = stack.dirtyDepth !== 0 || burstHasUncommittedEdit();
796
- document.title = (dirty ? '● ' : '') + baseTitle;
1085
+ document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
797
1086
  }
798
1087
 
799
1088
  // setDirty() is push-driven: something has to call it. MEASURED by keeping
@@ -865,6 +1154,12 @@
865
1154
  // refusal 一樣需要一個「後來的事件可以把它收掉」的記號,但收掉它的條件不
866
1155
  // 同(見 dismissSwallowBanner()),所以是另一個旗標而不是共用那一個。
867
1156
  let activeBannerIsSwallow = false;
1157
+ // Task 9 review I3: the same shape as activeBannerIsRefusal/
1158
+ // activeBannerIsSwallow above, for the keynav-exit banner backlog #10
1159
+ // added. It needs its own dismiss condition too — see
1160
+ // dismissKeynavExitBanner()'s own comment for why Escape, specifically,
1161
+ // is the only OTHER thing allowed to clear it.
1162
+ let activeBannerIsKeynavExit = false;
868
1163
  function showBanner(message, actionLabel, onAction) {
869
1164
  if (activeBanner) { activeBanner.remove(); activeBanner = null; }
870
1165
  // Reset unconditionally: refuseStructuralListEdit() sets it back to true
@@ -872,6 +1167,39 @@
872
1167
  // having to know this flag exists.
873
1168
  activeBannerIsRefusal = false;
874
1169
  activeBannerIsSwallow = false;
1170
+ activeBannerIsKeynavExit = false;
1171
+ // backlog #8 fix: `.ed-conflict` now sits at `top: var(--ed-toolbar-h)`
1172
+ // (lib/md2doc.js) — the SAME floor `.ed-te-menu` (placeTeMenuAboveCell())
1173
+ // explicitly clamps to when there is no room above (`if (top < topInset)
1174
+ // top = r.bottom + …`, `topInset` read straight from --ed-toolbar-h).
1175
+ // Final-review I1 correction: an earlier draft of this comment also named
1176
+ // `.ed-toolbar-menu` here (`toggleToolbarHeadingMenu(): top = r.bottom +
1177
+ // 4`) as if that `+ 4` were the same floor. Retracted — MEASURED:
1178
+ // toggleToolbarHeadingMenu() never reads --ed-toolbar-h at all; its `r` is
1179
+ // the heading-dropdown BUTTON's own rect (a toolbar button, so already
1180
+ // near the band), not a clamp against the token. It lands near the same
1181
+ // band as a side effect of where its trigger sits, not because of a
1182
+ // shared floor. (positionSelToolbar()'s `.ed-seltb` is the same story,
1183
+ // for the same reason — see the parallel correction on the `.ed-conflict`
1184
+ // CSS comment in lib/md2doc.js.)
1185
+ // Any of the four dynamic fixed overlays below could nonetheless be
1186
+ // showing in exactly the band the banner is about to claim; without this,
1187
+ // the banner (z-index 999, above all four) would paint over whichever one
1188
+ // is open, reproducing backlog #8's own defect one layer down instead of
1189
+ // fixing it. Same four functions onAnyScroll() already calls for an
1190
+ // analogous reason (something big just happened; these four don't
1191
+ // outlive it) — reused rather than re-invented — but NOT the identical
1192
+ // call: onAnyScroll() guards its hideTableEdgeMenu() with
1193
+ // `if (!suppressEdgeMenuAutoHide)`, this call is unconditional. The one
1194
+ // window that guard exists for is runCycleAlign()'s ensureTableBurstOpen()
1195
+ // span, which can itself raise a banner (a structural refusal) — but that
1196
+ // branch bails immediately and resets the flag to false before doing
1197
+ // anything the menu's reposition-in-progress state could be corrupted
1198
+ // by, so an unconditional close here has no real target to clobber.
1199
+ hideTableGrips();
1200
+ hideTableInsertBubbles();
1201
+ hideTableEdgeMenu();
1202
+ closeToolbarMenu();
875
1203
  const el = document.createElement('div');
876
1204
  el.className = 'ed-conflict';
877
1205
  const msg = document.createElement('span');
@@ -894,6 +1222,7 @@
894
1222
  activeBanner = null;
895
1223
  activeBannerIsRefusal = false;
896
1224
  activeBannerIsSwallow = false;
1225
+ activeBannerIsKeynavExit = false;
897
1226
  }
898
1227
  });
899
1228
  el.appendChild(dismissBtn);
@@ -902,6 +1231,34 @@
902
1231
  return el;
903
1232
  }
904
1233
 
1234
+ // Task 9 review I3: MEASURED AT THE TIME — `.ed-conflict` was
1235
+ // `position:fixed; top:0; z-index:999` against `.ed-toolbar`'s
1236
+ // `top:0; height:44px; z-index:101`, so the keynav-exit banner sat OVER
1237
+ // the entire bar (23/23 buttons' centre points hit-tested to the banner,
1238
+ // not their own button) the moment it appeared. Task 10 (backlog #8)
1239
+ // since moved `.ed-conflict` to `top: var(--ed-toolbar-h)` — it no longer
1240
+ // covers the bar at all, for any banner, not just this one — so that
1241
+ // geometry claim is HISTORICAL, kept here only because it is what
1242
+ // motivated wiring Escape below; do not read it as the current state of
1243
+ // `.ed-conflict`. What is still current: a keyboard user had no way to
1244
+ // take this banner down other than a mouse click on ✕ — Escape did not
1245
+ // dismiss it (showBanner() only wires a click handler onto ✕) and a
1246
+ // bare Tab is swallowed by this file's own §3.5 contract whenever nothing
1247
+ // real is focused, which is exactly the state right after this banner's
1248
+ // own cause (a stray key that just exited keynav). Escape is the one key
1249
+ // safe to wire this to: every OTHER banner this file raises (conflict /
1250
+ // save-failed / a structural refusal) describes something that must
1251
+ // survive an incidental Escape elsewhere on the page, but THIS banner's
1252
+ // entire subject is "you just left keyboard mode" — Escape asking to
1253
+ // leave a mode a second time is the same request, not a different one.
1254
+ function dismissKeynavExitBanner() {
1255
+ if (!activeBanner || !activeBannerIsKeynavExit) return false;
1256
+ activeBanner.remove();
1257
+ activeBanner = null;
1258
+ activeBannerIsKeynavExit = false;
1259
+ return true;
1260
+ }
1261
+
905
1262
  // Review recommendation 5: a refusal banner must not outlive the gesture that
906
1263
  // raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
907
1264
  // S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
@@ -1104,6 +1461,13 @@
1104
1461
  // guard in restoreDiscardedBurst() would refuse it anyway; dropping it at
1105
1462
  // the source is what keeps a dead history object from being held.
1106
1463
  dropDiscardedBurst();
1464
+ // v3.4.0 (Task 7 item 5): same reasoning, same source, for the raw-editor
1465
+ // twin — this render is about to replace `lines`' rendering of whatever
1466
+ // discardedRawEdit's stashed `source` names.
1467
+ dropDiscardedRawEdit();
1468
+ // v3.4.0 batch3 Task 7: and the wave twin — its staleness guard compares
1469
+ // `lines` at a span, which this render is about to repaint from.
1470
+ dropDiscardedWaveEdit();
1107
1471
  const scrollY = window.scrollY;
1108
1472
  // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
1109
1473
  // for the rebuilt selection. Consumed HERE, before the first failure exit,
@@ -1182,10 +1546,17 @@
1182
1546
  // Its VALUE is forwarded to patchmap(), which never reads it (決議 4(b) — it
1183
1547
  // is advisory, and seeding the span from an op's line range measurably
1184
1548
  // widens the answer), so neither the direction nor the width of the range
1185
- // affects the plan. `lastParts` is null
1186
- // on the first render of a session (the bootstrap payload carries no parts),
1187
- // which makes patchmap() return null — so the first commit after a page load
1188
- // is ALWAYS a full render. That is specified behaviour, not a bug.
1549
+ // affects the plan. The first commit after a page load is ALWAYS a full
1550
+ // render. That is specified behaviour, not a bug — but fix round 2
1551
+ // (re-review G2) corrects HOW it is carried: it used to be that `lastParts`
1552
+ // stayed null until a render landed, and null is what made this gate fall
1553
+ // through. Since the F1 fix, `lastParts` is seeded off the server-rendered
1554
+ // DOM at first paint and is NEVER null after that; the property is carried
1555
+ // by `lastPartsIsBootstrapSeed` instead (the seed is the HTML parser's
1556
+ // re-serialisation, not the server's own `parts` strings, so it must not
1557
+ // reach patchmap(), which diffs those strings). Do NOT use
1558
+ // `lastParts === null` as an "unprimed" test anywhere — it is now always
1559
+ // false.
1189
1560
  // Fix round 2: applyPatch()'s index arithmetic addresses `contentEl`'s
1190
1561
  // element children POSITIONALLY against `lastParts`. That correspondence is
1191
1562
  // not free — several gestures mutate .content's child list DIRECTLY, in the
@@ -1319,7 +1690,7 @@
1319
1690
  absorbedOutsideContent(lastRenderLines, blocks,
1320
1691
  lines, j.blocks,
1321
1692
  j.blocks[j.blocks.length - 1]);
1322
- const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
1693
+ const plan = (editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
1323
1694
  ? window.md2docPatchmap.patchmap({
1324
1695
  oldBlocks: blocks, newBlocks: j.blocks,
1325
1696
  oldParts: lastParts, newParts: j.parts, editRange: editRange,
@@ -1424,6 +1795,51 @@
1424
1795
  const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
1425
1796
  const focusWasInSpan = ci >= 0 &&
1426
1797
  ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
1798
+ // v3.4.0 (Task 7 item 1) — the raw-editor twin of applyFullRender()'s
1799
+ // v3.2.1 R2 `activeEditor = null` fix (see its comment), narrowed to the
1800
+ // one case that fix actually needs to cover here. That fix cannot be
1801
+ // copied verbatim: the whole point of the patch path is to let a raw
1802
+ // editor OUTSIDE replaceSpan survive (see "決議 3(1)" below) — nulling
1803
+ // activeEditor unconditionally here would re-orphan exactly the session
1804
+ // item1-scenarioB measured. The hazard is narrower: when activeEditor's
1805
+ // OWN block falls INSIDE replaceSpan, the removeChild loop below is
1806
+ // about to detach it out from under itself, and Chromium's synchronous
1807
+ // focusout for that detach finds `activeEditor` still set (commit()'s own
1808
+ // post-await `activeEditor = null` hasn't run yet — this render IS that
1809
+ // commit's own safeRerenderAll(), still in flight) — reentering
1810
+ // switchAwayFrom() -> resolveOpenSession() -> commitNow() a SECOND time.
1811
+ //
1812
+ // MEASURED (runs/task7-probe2-item1.js + task7-probe4-isolate.js, on a
1813
+ // 2nd-commit-in-session PATCH render that repairs a swallowed fence's
1814
+ // closing marker — the repair spawns a NEW block, same "衍生新 block"
1815
+ // shape the v3.2.1 R2 comment names): the reentrant commit() sees
1816
+ // `blocks` already reassigned to `j.blocks` (this function reassigns it
1817
+ // above, before the mutation below) — i.e. the block's NEW, shrunk
1818
+ // post-split range — but reuses `ta.value`, the STALE, un-shrunk text
1819
+ // that still carries the split-off tail. commitEdit() diffs that against
1820
+ // the (already-correct) `lines` and, since they no longer match, commits
1821
+ // a real op writing the stale tail-bearing text over the shrunk range —
1822
+ // duplicating the tail the FIRST (outer) commit had just placed in its
1823
+ // own new block a moment earlier. Three /api/render calls for one
1824
+ // Ctrl+Enter; the file gained a second 'Trailing paragraph text.'.
1825
+ // Nulling activeEditor here, before the mutation, for exactly the
1826
+ // falls-inside-replaceSpan case closes the hole the same way v3.2.1 R2
1827
+ // closed it for the fallback path, without touching the survives case.
1828
+ //
1829
+ // PRECONDITION worth stating explicitly: this can only fire on a PATCH
1830
+ // render, and patchmap() only runs once `lastParts` is populated — the
1831
+ // very first commit of a session is ALWAYS a full render (see
1832
+ // applyRenderResult()'s own comment). So the defect does NOT reproduce
1833
+ // on a freshly opened document with no prior commit in the same
1834
+ // session; repairing a swallowed fence as literally the first edit
1835
+ // after page load is safe (fallback path). See task-7-report.md and
1836
+ // this fix's test scenario for the measured before/after.
1837
+ if (activeEditor && activeEditor.blockEl) {
1838
+ const aci = children.indexOf(activeEditor.blockEl);
1839
+ if (aci >= plan.replaceSpan.oldStart && aci <= plan.replaceSpan.oldEnd) {
1840
+ activeEditor = null;
1841
+ }
1842
+ }
1427
1843
  // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1428
1844
  // between the module's model and the DOM, and they are overwritten BEFORE
1429
1845
  // the DOM is touched — armEditables() reads the new `blocks` (see its call
@@ -1534,8 +1950,15 @@
1534
1950
  // 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
1535
1951
  // 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
1536
1952
  // 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
1537
- // teardown 各項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1538
- // 仍然在 DOM 手術之後求值。
1953
+ // teardown 各項彼此的相對順序沒有被動到,**條件式**那一發 activeEditor
1954
+ // 歸零仍然在 DOM 手術之後求值——但這不再是這次 render 唯一可能歸零
1955
+ // `activeEditor` 的地方:v3.4.0 (Task 7 item 1) 在 DOM 手術**之前**另外
1956
+ // 補了一道 pre-mutation 的窄範圍歸零(見 `focusWasInSpan` 正下方那段
1957
+ // `if (activeEditor && activeEditor.blockEl) { … }`),只在
1958
+ // `activeEditor.blockEl` 落在 `plan.replaceSpan` 內時才提前清空。兩者
1959
+ // 不衝突(提前清過的話這裡條件式那段的 `activeEditor &&` 直接短路),但
1960
+ // 讀這段時不要再假設「這次 render 裡 activeEditor 的歸零只有一處、而且
1961
+ // 一定在 DOM 手術之後」。
1539
1962
  const newLen = plan.replaceSpan.newParts.length;
1540
1963
  if (newLen > 0) {
1541
1964
  // landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
@@ -1821,6 +2244,23 @@
1821
2244
  // Never let a reader-runtime rebind failure break block selection/save/undo.
1822
2245
  }
1823
2246
  }
2247
+ // v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
2248
+ // __md2docInitDiagrams above, and for the same reason — the swap just
2249
+ // detached every node the multi-page drawio navigation was mounted on,
2250
+ // and nothing else in the tree re-creates it. Left unwired, a document
2251
+ // with a multi-page diagram loses that navigation permanently on the
2252
+ // user's FIRST edit commit of the session, which makes preserving the
2253
+ // reader's open page across a background re-bake largely academic.
2254
+ // Defined only when the document was multi-page at load time (the
2255
+ // lib/md2doc.js gate a byte-exact guard test pins), hence the check.
2256
+ if (window.__md2docInitDrawioSheetBar) {
2257
+ try {
2258
+ window.__md2docInitDrawioSheetBar(contentEl);
2259
+ } catch (e) {
2260
+ // Same contract as the two rebinds around it: never let a
2261
+ // navigation-rebuild failure break block selection / save / undo.
2262
+ }
2263
+ }
1824
2264
  // Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
1825
2265
  // above does NOT take its early-return path it has already called
1826
2266
  // applySelectionClasses() once, via setBlockSelection()/
@@ -1887,6 +2327,7 @@
1887
2327
  async function applyFullRender(j, scrollY, declaredRange) {
1888
2328
  blocks = j.blocks;
1889
2329
  lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
2330
+ lastPartsIsBootstrapSeed = false;
1890
2331
  // v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
1891
2332
  // textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
1892
2333
  // commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
@@ -2029,6 +2470,23 @@
2029
2470
  // Never let a reader-runtime rebind failure break block selection/save/undo.
2030
2471
  }
2031
2472
  }
2473
+ // v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
2474
+ // __md2docInitDiagrams above, and for the same reason — the swap just
2475
+ // detached every node the multi-page drawio navigation was mounted on,
2476
+ // and nothing else in the tree re-creates it. Left unwired, a document
2477
+ // with a multi-page diagram loses that navigation permanently on the
2478
+ // user's FIRST edit commit of the session, which makes preserving the
2479
+ // reader's open page across a background re-bake largely academic.
2480
+ // Defined only when the document was multi-page at load time (the
2481
+ // lib/md2doc.js gate a byte-exact guard test pins), hence the check.
2482
+ if (window.__md2docInitDrawioSheetBar) {
2483
+ try {
2484
+ window.__md2docInitDrawioSheetBar(contentEl);
2485
+ } catch (e) {
2486
+ // Same contract as the two rebinds around it: never let a
2487
+ // navigation-rebuild failure break block selection / save / undo.
2488
+ }
2489
+ }
2032
2490
  setDirty();
2033
2491
  return true;
2034
2492
  }
@@ -2440,6 +2898,47 @@
2440
2898
  async function cancelAndMaybeDiscard() {
2441
2899
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
2442
2900
  if (wasPristineForThisBlock) pristineInsert = null;
2901
+ // v3.4.0 (Task 7 item 5): stash what is about to be thrown away, keyed
2902
+ // by VALUE rather than by node — see discardedRawEdit's own comment for
2903
+ // why a node-keyed stash (discardedBurst's own approach) cannot survive
2904
+ // this function's own restore() just below: for the non-rangeMode
2905
+ // branch it does `blockEl.innerHTML = original`.
2906
+ //
2907
+ // ⚠ rangeMode (a multi-block span, or any li) is NOT covered by this
2908
+ // stash, and that is a known, unfixed gap rather than something this
2909
+ // branch happens to also solve — review round 2 measured it on HEAD:
2910
+ // restore()'s rangeMode branch calls `await safeRerenderAll()`, which
2911
+ // reaches rerenderAll(), which calls dropDiscardedRawEdit()
2912
+ // UNCONDITIONALLY a couple of lines into the very Escape gesture that
2913
+ // just created the stash below. So the `discardedRawEdit = {...}`
2914
+ // assignment two lines down is live code but its EFFECT is dead for
2915
+ // every rangeMode session — the stash exists for one statement and is
2916
+ // wiped by its own creator's next `await`. A single li ( -item one ->
2917
+ // ⠿ -> MD 原始碼 -> type -> Escape -> Ctrl+Z) and a multi-block span
2918
+ // both measure `rawAfterUndo: {present:false}` — Ctrl+Z does not bring
2919
+ // the text back on either shape. Fixing this is out of this task's
2920
+ // scope (parked to backlog); this comment exists so nobody reads the
2921
+ // code below as covering rangeMode when it structurally cannot.
2922
+ // Skipped for a pristine insert: discardPristineInsert() (below)
2923
+ // REMOVES the block outright, so there is no surface left to restore a
2924
+ // stash INTO — mirrors revertBurstAndEnd()'s identical
2925
+ // wasPristineForThisBlock exception for the WYSIWYG burst twin.
2926
+ // Skipped when nothing was actually typed (ta.value === seed.value):
2927
+ // the zero-edit auto-cancel-on-unchanged-blur path in
2928
+ // resolveOpenSession() reaches this same function, and there is
2929
+ // nothing to lose there.
2930
+ if (!wasPristineForThisBlock && ta.value !== seed.value) {
2931
+ // Mutual exclusivity with the WYSIWYG twin — see discardedRawEdit's
2932
+ // own comment for why only the more recent of the two stashes may
2933
+ // ever answer the next Ctrl+Z.
2934
+ dropDiscardedBurst();
2935
+ // …and with the wave twin, for the same reason.
2936
+ dropDiscardedWaveEdit();
2937
+ discardedRawEdit = {
2938
+ blockId, range, startLine, endLine, source,
2939
+ value: ta.value, caret: ta.selectionStart,
2940
+ };
2941
+ }
2443
2942
  await restore();
2444
2943
  if (wasPristineForThisBlock) return await discardPristineInsert();
2445
2944
  return true;
@@ -4346,6 +4845,10 @@
4346
4845
  await openRawEditor(blockEl);
4347
4846
  return;
4348
4847
  }
4848
+ // NOTE: `kind === 'line'` never reaches here — insertBlockBelow()'s own
4849
+ // caller excludes it (`if (!customLines && kind !== 'line')`) and
4850
+ // handles the hr's caret landing itself, right where it computes
4851
+ // `result.newStartLine`; see that branch's own backlog #6 comment.
4349
4852
  if (kind === 'table') {
4350
4853
  const tableEl = blockContentEl(blockEl);
4351
4854
  const firstBodyRow = tableEl ? bodyRowsOf(tableEl)[0] : null;
@@ -4537,7 +5040,24 @@
4537
5040
  // no declareCollapse(), so re-aiming the bar at the hr cannot contradict
4538
5041
  // a tinted set the way a forced caret would — but "the one site that does
4539
5042
  // not ask the question" is how the next divergence gets in.
4540
- if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
5043
+ if (!blockSelection) {
5044
+ reaimToolbarBlockAtLine(result.newStartLine);
5045
+ // backlog #6: the "restore the caret too" decision the comment above
5046
+ // deferred. armEditables() has no 'line' branch (same degraded shape
5047
+ // as 'code' — see focusInsertedBlock()'s own branch for the
5048
+ // established precedent), so an <hr> has no contenteditable surface
5049
+ // to land a caret in at all; its own raw editor is the only real
5050
+ // landing. MEASURED (v3.3.0, this comment's own log above):
5051
+ // activeElement stayed BODY whether this branch ran or not — this
5052
+ // closes exactly that gap rather than picking "the anchor block" or
5053
+ // "the block after the hr", the two options the deferred comment
5054
+ // named: neither is where the user's attention actually is right
5055
+ // after pressing 分隔線, which is the hr itself.
5056
+ const target = blocks.find((b) => b.startLine === result.newStartLine);
5057
+ const hrEl = target &&
5058
+ document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5059
+ if (hrEl) await openRawEditor(hrEl);
5060
+ }
4541
5061
  }
4542
5062
  }
4543
5063
 
@@ -4926,7 +5446,32 @@
4926
5446
  // this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
4927
5447
  // caught by safeRerenderAll(): that can leave the DOM half-swapped, and
4928
5448
  // neither the old behaviour nor this one has an answer for it.
4929
- function restoreAfterStructuralOp(focusLine, anchorLine) {
5449
+ // `allowRawEditRescue` (Task 9 review I4): the backlog #6 raw-editor
5450
+ // fallback below is opt-in per CALLER, not a blanket behaviour of this
5451
+ // shared landing function. restoreAfterStructuralOp() has four callers —
5452
+ // changeHeadingDepth / convertBlockViaMenu / duplicateBlockViaMenu /
5453
+ // deleteBlockViaGutter — and backlog #6's own text names exactly one
5454
+ // gesture family (轉換成 quote/code), reached only through
5455
+ // convertBlockViaMenu(). MEASURED (review): the guard as first written
5456
+ // was unconditional and DID also fire for 建立副本 (duplicate) on a code
5457
+ // block or a blockquote, and for 刪除 (delete) when the surviving
5458
+ // neighbour was a blockquote — BODY → TEXTAREA.ed-raw in all three,
5459
+ // philosophically consistent (armEditables() never arms quote/code
5460
+ // regardless of which gesture produced one) but never asked for, never
5461
+ // reported, and never tested.
5462
+ // Final-review I2 correction: this paragraph used to also cite backlog #5
5463
+ // (the raw editor's own Escape discarding an uncommitted edit) as a
5464
+ // reason auto-opening one right after a DESTRUCTIVE gesture
5465
+ // (duplicate/delete) would not be free the way it is right after a plain
5466
+ // conversion. Backlog #5 was fixed by 3eb36f9, which landed BEFORE this
5467
+ // very commit on this branch — Escape out of a raw editor no longer
5468
+ // discards the edit, so that risk no longer applies. Retracted rather than
5469
+ // left to mislead whoever next considers widening this rescue. The
5470
+ // narrowing itself still stands on its own merits above (never asked for,
5471
+ // never reported, never tested for duplicate/delete) — widening past
5472
+ // convertBlockViaMenu() to the other three callers remains a separate
5473
+ // decision this task did not make and should not make silently.
5474
+ function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
4930
5475
  // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
4931
5476
  // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
4932
5477
  if (blockSelection) return;
@@ -4935,6 +5480,27 @@
4935
5480
  // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
4936
5481
  // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
4937
5482
  reaimToolbarBlockAtLine(focusLine);
5483
+ // backlog #6: focusBlockAtLine()'s no-op above is exactly the failure
5484
+ // mode this closes — armEditables() never arms quote/code (no branch
5485
+ // for either in its own condition), so blockContentEl() there hands
5486
+ // back a plain, non-focusable <blockquote>/<pre> and .focus() on it
5487
+ // is silently inert. MEASURED (v3.3.0): 轉換成 quote/code/line left
5488
+ // the caret on BODY with NOTHING to click into — "a mouse user climbs
5489
+ // out by clicking into the text" (activateToolbarCursor()'s own
5490
+ // comment) does not even apply here, since there is no editable text
5491
+ // to click into. The established way to edit either type at all is
5492
+ // the ⠿ menu's own MD 原始碼 raw editor (openRawEditor(), the same
5493
+ // one focusInsertedBlock()'s `kind === 'code'` branch already opens
5494
+ // for a freshly INSERTED code block) — reused here rather than
5495
+ // invented, so the landing this action leaves someone in is the
5496
+ // block's real, only editing surface, not a caret parked over
5497
+ // uneditable prose.
5498
+ if (allowRawEditRescue && document.activeElement === document.body) {
5499
+ const target = blocks.find((b) => b.startLine === focusLine);
5500
+ const blockEl = target &&
5501
+ document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5502
+ if (blockEl) openRawEditor(blockEl).catch(() => {});
5503
+ }
4938
5504
  return;
4939
5505
  }
4940
5506
  if (anchorLine == null) return;
@@ -5041,7 +5607,10 @@
5041
5607
  // 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
5042
5608
  // 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
5043
5609
  // 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
5044
- restoreAfterStructuralOp(focusLine, anchor.line);
5610
+ // Task 9 review I4: `true` — this is the ONE call site backlog #6's
5611
+ // raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
5612
+ // own `allowRawEditRescue` comment.
5613
+ restoreAfterStructuralOp(focusLine, anchor.line, true);
5045
5614
  }
5046
5615
  }
5047
5616
 
@@ -6269,6 +6838,13 @@
6269
6838
 
6270
6839
  function startBurst(editEl) {
6271
6840
  dropDiscardedBurst();
6841
+ // v3.4.0 (Task 7 item 5): opening ANY new session — burst or raw —
6842
+ // invalidates a still-parked raw-edit discard from a different block; see
6843
+ // discardedRawEdit's own comment for why the two stashes must stay
6844
+ // mutually exclusive.
6845
+ dropDiscardedRawEdit();
6846
+ // v3.4.0 batch3 Task 7: …and the wave twin, same rule, three stashes.
6847
+ dropDiscardedWaveEdit();
6272
6848
  const blockEl = editEl.closest('.ed-block');
6273
6849
  if (!blockEl) return;
6274
6850
  const blockId = Number(blockEl.getAttribute('data-block-id'));
@@ -6714,6 +7290,19 @@
6714
7290
  }
6715
7291
  function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
6716
7292
 
7293
+ // Whether a key event landed in something that takes typed characters —
7294
+ // ANY such thing, wherever it is mounted. The block-selection key branch
7295
+ // documents "focus is on a block wrapper and not on any text surface" as its
7296
+ // precondition; this is that sentence as a check. `isContentEditable` covers
7297
+ // the WYSIWYG surfaces without naming their classes, and the three tag names
7298
+ // cover controls mounted anywhere, `.content` or not.
7299
+ function isTextEntryTarget(el) {
7300
+ if (el === null || el === undefined) return false;
7301
+ if (el.isContentEditable === true) return true;
7302
+ const tag = el.tagName;
7303
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
7304
+ }
7305
+
6717
7306
  // ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
6718
7307
  // `contentEl.innerHTML` is replaced wholesale on every commit, so the
6719
7308
  // painted tint and the roving focus holder are destroyed by definition —
@@ -7493,6 +8082,10 @@
7493
8082
  burst.history.flushTyping();
7494
8083
  editEl.innerHTML = burst.original;
7495
8084
  burst.history.snap('escape-discard');
8085
+ // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8086
+ // see discardedRawEdit's own comment.
8087
+ dropDiscardedRawEdit();
8088
+ dropDiscardedWaveEdit();
7496
8089
  discardedBurst = {
7497
8090
  history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
7498
8091
  reverted: burstBaselineHtml(editEl),
@@ -7511,6 +8104,10 @@
7511
8104
  burst.history.flushTyping();
7512
8105
  revertFn();
7513
8106
  burst.history.snap('escape-discard');
8107
+ // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8108
+ // see discardedRawEdit's own comment.
8109
+ dropDiscardedRawEdit();
8110
+ dropDiscardedWaveEdit();
7514
8111
  discardedBurst = {
7515
8112
  history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
7516
8113
  reverted: burstBaselineHtml(tableEl),
@@ -7580,6 +8177,62 @@
7580
8177
  return true;
7581
8178
  }
7582
8179
 
8180
+ // v3.4.0 (Task 7 item 5) — the raw-editor twin of restoreDiscardedBurst()
8181
+ // just above. Ctrl+Z with no burst/raw editor open, when the last thing
8182
+ // that happened was an Escape/✕ out of a raw editor: RE-OPEN that same raw
8183
+ // editor and put the discarded text back into it — a live, dirty edit
8184
+ // again (hasChanges() reads true against the fresh seed, so the next
8185
+ // blur/Ctrl+Enter commits it, and a further Ctrl+Z steps back through the
8186
+ // reopened session same as any other raw edit). Escape's own semantics —
8187
+ // discard, and leave — are unchanged; only "gone forever" is.
8188
+ //
8189
+ // Staleness guard is asked at the `lines`/`blocks` level rather than the
8190
+ // DOM level restoreDiscardedBurst() uses (`document.body.contains(editEl)`
8191
+ // + a baseline-HTML comparison) — openRawEditor()'s own restore() tears
8192
+ // the textarea's DOM down unconditionally (see discardedRawEdit's own
8193
+ // comment), so there is no live node whose presence would even answer the
8194
+ // question. What has to hold instead is SIMILAR IN SPIRIT to what
8195
+ // reanchor() (this same file, openRawEditor()'s own re-derivation for a
8196
+ // session that survives a patch) asks — same two questions, "does the
8197
+ // block id still name a block?" and "are the bytes at its span still what
8198
+ // this session last saw?" — but it is NOT the same predicate, and the two
8199
+ // must not be conflated: reanchor()'s very first line is
8200
+ // `if (range) return false;`, i.e. it refuses EVERY rangeMode session
8201
+ // outright. The check below has no such refusal — for a single-block
8202
+ // (non-range) discard it asks the two questions above and nothing else.
8203
+ // In practice this asymmetry is moot for rangeMode: discardedRawEdit is
8204
+ // never actually populated for a rangeMode session in the first place
8205
+ // (see cancelAndMaybeDiscard()'s own comment — restore()'s rangeMode
8206
+ // branch wipes the stash via rerenderAll()'s unconditional
8207
+ // dropDiscardedRawEdit() before a Ctrl+Z could ever reach here), so this
8208
+ // function's range-shaped `d.range` field is carried for openRawEditor()'s
8209
+ // signature and is never exercised by a live stash today. Returns false
8210
+ // whenever there is nothing to restore or the stash has gone stale; the
8211
+ // caller then falls through to the ordinary document undo, same contract
8212
+ // as restoreDiscardedBurst().
8213
+ async function restoreDiscardedRawEdit() {
8214
+ const d = discardedRawEdit;
8215
+ discardedRawEdit = null;
8216
+ if (!d) return false;
8217
+ const b = blocks.find((x) => x.id === d.blockId);
8218
+ if (!b || b.startLine !== d.startLine || b.endLine !== d.endLine) return false;
8219
+ if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
8220
+ const blockEl = blockElById(d.blockId);
8221
+ if (!blockEl) return false;
8222
+ await openRawEditor(blockEl, d.range);
8223
+ // Re-queried rather than reusing `blockEl` above: openRawEditor()'s own
8224
+ // guard may have resolved a DIFFERENT open session first (switchAwayFrom()),
8225
+ // which can re-render and hand out a fresh node for the same block id.
8226
+ const liveBlockEl = blockElById(d.blockId);
8227
+ const ta = liveBlockEl && liveBlockEl.querySelector('.ed-raw');
8228
+ if (!ta) return false; // a refusal (or a lost race) beat us to it
8229
+ ta.value = d.value;
8230
+ const caret = Math.max(0, Math.min(d.value.length, d.caret));
8231
+ ta.setSelectionRange(caret, caret);
8232
+ autoSize(ta);
8233
+ return true;
8234
+ }
8235
+
7583
8236
  // Ctrl+Z inside a burst: step the burst-local history first; only once
7584
8237
  // it's exhausted (atBottom — the surface is already back to its pre-focus
7585
8238
  // baseline) does this cascade OUT to the document-level undo() stack,
@@ -8757,6 +9410,9 @@
8757
9410
  // re-armed table, not a plain static one).
8758
9411
  function startTableBurst(cellEl) {
8759
9412
  dropDiscardedBurst();
9413
+ // v3.4.0 (Task 7 item 5): see startBurst()'s own comment just above.
9414
+ dropDiscardedRawEdit();
9415
+ dropDiscardedWaveEdit();
8760
9416
  const tableEl = cellEl.closest('table');
8761
9417
  const blockEl = tableEl && tableEl.closest('.ed-block');
8762
9418
  if (!blockEl) return;
@@ -8823,9 +9479,13 @@
8823
9479
  // time this focusin resumes. Instrumented on the F8 fixture (a
8824
9480
  // paragraph left dirty, then a press into the table): the mousedown's
8825
9481
  // focusout resolves that paragraph, and applyRenderResult()'s patch
8826
- // gate consults domMatchesLastRender(), which reports false while
8827
- // `lastParts` is still null — as it is until a render has landed — so
8828
- // the render goes to applyFullRender(), whose `contentEl.innerHTML =`
9482
+ // gate consults domMatchesLastRender(). MEASURED AT THE TIME, when
9483
+ // `lastParts` was still null until a render landed and that is what made
9484
+ // the gate report false. Fix round 2 (re-review G2): `lastParts` is
9485
+ // seeded at first paint now and is never null, so the route below is
9486
+ // reached through `lastPartsIsBootstrapSeed` instead — the ROUTE and the
9487
+ // symptom are unchanged, only the reason the gate declines. The render
9488
+ // goes to applyFullRender(), whose `contentEl.innerHTML =`
8829
9489
  // rewrites .content wholesale. A probe around the `await` above
8830
9490
  // recorded route ["applyFullRender"] together with
8831
9491
  // `document.body.contains(cellEl)` false, on each reproduction driven.
@@ -9372,28 +10032,104 @@
9372
10032
  // here then names a DIFFERENT block. `classList.contains('ed-wys-table')`
9373
10033
  // was the only guard, and every table passes it, so the gesture silently
9374
10034
  // rewrote an untouched neighbouring table (a row drag even promoted one of
9375
- // its body rows to its header). Re-resolve by `startLine` instead — see
9376
- // blockElAtLine()'s own comment: a startLine captured before a commit is
9377
- // the only stable way back to a specific block afterwards — and then
9378
- // verify the block we landed on really is the same table (header cell
9379
- // count, header cell text, row count) before handing it to a caller that
9380
- // is about to mutate it. A commit that shifts this table's own start line
9381
- // (an insert/delete ABOVE it) leaves nothing to resolve; returning null
9382
- // drops the gesture, which is the conservative half of the trade — never
9383
- // mutating the wrong table beats completing every gesture.
10035
+ // its body rows to its header). The fix added `startLine` — see
10036
+ // blockElAtLine()'s own comment — and a real identity check (header cell
10037
+ // count, header cell text, row count) before handing the block to a caller
10038
+ // that is about to mutate it.
10039
+ //
10040
+ // v3.4.0 Task 12: `startLine` ALONE carries the same hazard, one shape
10041
+ // over. DRIVEN, not argued (the v3.3.0 final review tried and could not,
10042
+ // because its fixture's commit never detached the table, so this branch
10043
+ // never ran at all): a six-line paragraph above the table, its raw editor
10044
+ // left dirty with a ONE-line replacement, then a row drag on the table.
10045
+ // The drop's own switchAwayFrom() commits that raw edit — the session's
10046
+ // first render, so it is a full `.content` rewrite and `tableEl` really is
10047
+ // detached here — and every block below moves up 5 lines. With the two
10048
+ // tables 5 lines apart, the SECOND table's new startLine is exactly the
10049
+ // first one's captured one; with matching header cells and row counts,
10050
+ // tableIdentityOf() agrees; and the drag reordered the neighbour's rows on
10051
+ // screen and on disk while the dragged table sat untouched. Instrumented
10052
+ // run and assertion: test/table-anchor-recovery.test.js.
10053
+ //
10054
+ // So this asks the SAME composite anchor handleTableCellFocusIn() above
10055
+ // asks, for the same reason its comment writes down — the two handles fail
10056
+ // on DIFFERENT commits (a line added ahead of the table with the block
10057
+ // count unchanged breaks startLine but not the id; a block added ahead of
10058
+ // it with the line count unchanged breaks the id but not startLine), so
10059
+ // neither subsumes the other and the id is asked first. Whichever answers,
10060
+ // tableIdentityOf() still has to agree. A commit that defeats BOTH handles
10061
+ // leaves nothing to resolve; returning null drops the gesture, which is
10062
+ // the conservative half of the trade — never mutating the wrong table
10063
+ // beats completing every gesture.
10064
+ // v3.4.0 Task 12: this used to be row count + HEADER cell text only, and
10065
+ // that is exactly as much as two different tables in one document can
10066
+ // share by accident — "| A | B |" over two rows describes an enormous
10067
+ // number of real tables, and a document that repeats a header (a register
10068
+ // map, a pin list, one table per block) has several. Both call sites use
10069
+ // this to decide whether a block they re-resolved by a possibly-stale
10070
+ // handle is the table the gesture started on, so a collision there is not
10071
+ // a near miss: the row drag Task 12 drove reordered the NEIGHBOUR's rows,
10072
+ // on screen and on disk, while the dragged table sat untouched. Every
10073
+ // row's cells go in, so two tables collide only when their rendered text
10074
+ // is identical cell for cell.
10075
+ //
10076
+ // The commit being recovered from is always some OTHER block's (a burst on
10077
+ // THIS table early-returns before any of this), so this table's own
10078
+ // rendered text is what it was when the handle was captured — the stronger
10079
+ // identity refuses collisions without refusing the table itself.
10080
+ //
10081
+ // The delimiters are written as ESCAPES, not as literal control bytes.
10082
+ // They used to be literal, which made grep classify this whole 9643-line
10083
+ // file as binary and SILENTLY TRUNCATE its output — a real investigation
10084
+ // was misled by it. The runtime value is identical.
9384
10085
  function tableIdentityOf(tableEl) {
9385
10086
  const headerRow = tableEl ? headerRowOf(tableEl) : null;
9386
10087
  if (!headerRow) return null;
9387
- // The two delimiters are written as ESCAPES, not as literal control
9388
- // bytes. They used to be literal, which made grep classify this whole
9389
- // 9643-line file as binary and SILENTLY TRUNCATE its output — a real
9390
- // investigation was misled by it. The runtime value is identical.
9391
- return allRowsOf(tableEl).length + '\x01' +
9392
- Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('\x00');
9393
- }
9394
- async function ensureTableBurstOpen(tableEl) {
10088
+ const rows = allRowsOf(tableEl);
10089
+ return rows.length + '\x01' + rows.map((r) =>
10090
+ Array.prototype.slice.call(r.cells).map((c) => c.textContent).join('\x00')).join('\x02');
10091
+ }
10092
+ // v3.4.0 §2: `forDrag` narrows the policy above for the ROW/COLUMN DRAG
10093
+ // callers only (performRowDrop()/performColDrop() below) — every other
10094
+ // caller (the insert-row/insert-column hover bubbles, cycleAlign(),
10095
+ // deleteRow/deleteColumn's own callers) keeps focusing cells[0] with a
10096
+ // normal, scrolling focus() exactly as before, unchanged.
10097
+ //
10098
+ // Why a drag needs a DIFFERENT policy, not just a smaller guard elsewhere:
10099
+ // a drag gesture never had a focused cell of its own, yet THIS function's
10100
+ // own `cell.focus()` a few lines down is what actually opens the burst in
10101
+ // the first place — the delegated focusin listener
10102
+ // (handleTableCellFocusIn() above) is the ONLY path that calls
10103
+ // startTableBurst(), so the call can't simply be skipped for a drag; the
10104
+ // burst would never open at all, and every existing row/column drag
10105
+ // scenario that asserts "a drop auto-starts a burst" (a cell must be
10106
+ // focused afterward) would break. What can change for a drag, since it
10107
+ // never had an opinion about WHICH cell should end up focused, is WHICH
10108
+ // cell this picks and HOW it focuses it:
10109
+ // - `{ preventScroll: true }` — a plain focus() with no options scrolls
10110
+ // its target into view. A stack-traced probe (measured, not guessed —
10111
+ // see performRowDrop()'s own comment below for the full write-up)
10112
+ // found this is only HALF the "drag jumps to the header" symptom's
10113
+ // origin: restoreTableFocus() (below) always re-resolves and
10114
+ // re-focuses the SAME cell this call already focused — there is no
10115
+ // way to skip that second focus() by checking an index, since the
10116
+ // index it would check is always valid by the time it's read (same
10117
+ // comment). So restoreTableFocus()'s own focus() ALSO needs
10118
+ // `preventScroll: true` — this call alone was never going to be
10119
+ // enough.
10120
+ // - the first BODY cell (a <td>), not cells[0] (the header <th>) — a
10121
+ // table drag's own regression scenario asserts the resulting active
10122
+ // element is not a header cell. Every cell (header or body) already
10123
+ // carries 'ed-wys-cell' (armEditables()'s table branch, arm time), so
10124
+ // a body cell satisfies "a drop auto-starts a burst" exactly as well
10125
+ // as the header did — and restoreTableFocus() re-focuses whichever
10126
+ // cell THIS picks, so picking a body cell here is what keeps its own
10127
+ // re-focus off the header too. Falls back to cells[0] only when the
10128
+ // table has no body row at all (nothing to drag in that case anyway).
10129
+ async function ensureTableBurstOpen(tableEl, forDrag) {
9395
10130
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
9396
10131
  const blockEl = tableEl.closest('.ed-block');
10132
+ const blockIdAttr = blockEl ? blockEl.getAttribute('data-block-id') : null;
9397
10133
  const blockId = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
9398
10134
  const block = blockId != null ? blocks.find((b) => b.id === blockId) : null;
9399
10135
  const startLine = block ? block.startLine : null;
@@ -9402,24 +10138,56 @@
9402
10138
  if (!ok) return null;
9403
10139
  let liveTableEl = tableEl;
9404
10140
  if (!document.body.contains(tableEl)) {
9405
- const liveBlockEl = startLine != null ? blockElAtLine(startLine) : null;
9406
- liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
9407
- // T7: both refusals used to `return null` in silence — the drag, the
10141
+ // Both handles are gated by the SAME identity check, so a candidate
10142
+ // that is not a live armed table, or is a table this gesture never
10143
+ // touched, simply does not answer and the next handle gets its turn —
10144
+ // rather than each handle carrying its own copy of the two refusals.
10145
+ const isThisTable = (t) => !!(t && t.classList &&
10146
+ t.classList.contains('ed-wys-table') && identity != null &&
10147
+ tableIdentityOf(t) === identity);
10148
+ const liveTableFrom = (candidateBlockEl) => {
10149
+ const t = candidateBlockEl ? blockContentEl(candidateBlockEl) : null;
10150
+ return isThisTable(t) ? t : null;
10151
+ };
10152
+ liveTableEl =
10153
+ liveTableFrom(blockIdAttr != null
10154
+ ? document.querySelector('.ed-block[data-block-id="' + blockIdAttr + '"]') : null) ||
10155
+ liveTableFrom(startLine != null ? blockElAtLine(startLine) : null);
10156
+ // v3.4.0 Task 12, second half: identity says "this LOOKS like the table
10157
+ // the gesture started on", and a handle says "and it is at the place I
10158
+ // remember". Neither says the answer was the only one available. DRIVEN
10159
+ // on two byte-identical tables (a shape a register map or a pin list
10160
+ // produces by itself) with the gesture on the SECOND one: the commit
10161
+ // renumbered the ids, the captured id landed on the FIRST twin, and
10162
+ // identity — which cannot tell twins apart, however much of the table
10163
+ // it hashes — waved it through. The drag reordered the first table's
10164
+ // rows and left the dragged one alone, on screen and on disk.
10165
+ //
10166
+ // So when the document holds more than one table this gesture could
10167
+ // have started on, no handle is allowed to break the tie: nothing here
10168
+ // knows which twin the user pressed, and the wrong guess rewrites a
10169
+ // table they never touched. Refuse instead — the same conservative half
10170
+ // of the trade this whole recovery already takes. Counted over
10171
+ // `contentEl` (not the whole document) so the grip/bubble chrome
10172
+ // outside it can never be mistaken for a candidate.
10173
+ if (liveTableEl && Array.prototype.slice.call(
10174
+ contentEl.querySelectorAll('.ed-wys-table')).filter(isThisTable).length !== 1) {
10175
+ liveTableEl = null;
10176
+ }
10177
+ // T7: the refusal used to `return null` in silence — the drag, the
9408
10178
  // insert, the alignment change simply did not happen and nothing on
9409
10179
  // screen said why, which is indistinguishable from a broken control.
9410
10180
  // Dropping is still the right answer (see the S1 comment above); saying
9411
10181
  // nothing was not.
9412
- if (!liveTableEl || !liveTableEl.classList || !liveTableEl.classList.contains('ed-wys-table')) {
9413
- showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
9414
- }
9415
- if (identity == null || tableIdentityOf(liveTableEl) !== identity) {
10182
+ if (!liveTableEl) {
9416
10183
  showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
9417
10184
  }
9418
10185
  }
9419
10186
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
9420
- const cell = tableCellsOf(liveTableEl)[0];
10187
+ const cells = tableCellsOf(liveTableEl);
10188
+ const cell = forDrag ? (cells.find((c) => c.tagName !== 'TH') || cells[0]) : cells[0];
9421
10189
  if (!cell) return null;
9422
- cell.focus();
10190
+ if (forDrag) cell.focus({ preventScroll: true }); else cell.focus();
9423
10191
  return (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl)
9424
10192
  ? liveTableEl : null;
9425
10193
  }
@@ -9449,8 +10217,40 @@
9449
10217
  // resolves to (which may differ from `tableEl` if a dirty burst
9450
10218
  // elsewhere just committed and swapped `.content` out from under this
9451
10219
  // click) — never the possibly-now-detached `tableEl` itself.
10220
+ //
10221
+ // v3.4.0 backlog #2 sister fix: same root cause as
10222
+ // onRowInsertBubbleClick() below — `colIndex` is set by
10223
+ // updateTableInsertBubbles() at an EARLIER hover and never re-validated
10224
+ // against the live table here. Unlike `afterRowIndex`, `colIndex` has
10225
+ // NO legal sentinel value at all (no "-1" case; updateTableInsertBubbles()
10226
+ // only ever assigns 0..headerCells.length-1, each meaning "insert after
10227
+ // column i"), so every value this dataset can legitimately hold must
10228
+ // name a real column on `liveTableEl`. A stale colIndex that no longer
10229
+ // does doesn't crash — insertColumn()'s `row.cells[colIndex]` lookup
10230
+ // silently comes back undefined, and `row.insertBefore(cell, ref ?
10231
+ // ref.nextSibling : null)` falls to its `null` branch, silently
10232
+ // APPENDING the new column at the very END of the table instead of
10233
+ // dropping the gesture. MEASURED (task-6-report.md): unreachable via
10234
+ // real gesture today, guarded by the exact same two independent
10235
+ // mechanisms as the row sibling (updateTableInsertBubbles()'s own
10236
+ // proximity-hide, and rerenderAll()'s unconditional
10237
+ // hideTableInsertBubbles()/hideTableGrips() on every full render) — kept
10238
+ // as defense-in-depth against the day either one is weakened, same
10239
+ // reasoning as the row fix.
9452
10240
  const liveTableEl = await ensureTableBurstOpen(tableEl);
9453
10241
  if (!liveTableEl) return;
10242
+ const headerRow = headerRowOf(liveTableEl);
10243
+ const targetCell = headerRow ? headerRow.cells[colIndex] : null;
10244
+ // No sentinel to exclude here (contrast afterRowIndex: -1 above) — any
10245
+ // colIndex that doesn't name a real column on the live table is stale.
10246
+ // Same policy as the DROPPED_GESTURE_MESSAGE precedent this file already
10247
+ // uses throughout (performBlockDrop()/performListItemDrop() et al.,
10248
+ // 15 call sites): can't relocate it on the live table -> drop the
10249
+ // gesture and say so, never guess.
10250
+ if (!targetCell) {
10251
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
10252
+ return;
10253
+ }
9454
10254
  currentBurst.history.snap('insert-col-pre');
9455
10255
  insertColumn(liveTableEl, colIndex);
9456
10256
  armNewTableCells(liveTableEl);
@@ -9467,22 +10267,42 @@
9467
10267
  // Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
9468
10268
  // above — the table element reference has to be re-resolved.
9469
10269
  //
9470
- // ⚠ RETRACTION. An earlier revision of this comment went on to say that
9471
- // `afterRowIndex`, being a plain integer, stays valid across that swap.
9472
- // Ruling T3-9 measured the opposite and parked the fix in v3.4.0: the
9473
- // index names a row of the table the bubble was positioned against, and
9474
- // after a handover it is read against a different element's row list, so
9475
- // `bodyRowsOf(liveTableEl)[afterRowIndex]` comes back `undefined`,
9476
- // `afterRow` is falsy, and insertRow() below takes its
9477
- // `tbody.insertBefore(newRow, tbody.firstChild)` branch — the same branch
9478
- // `afterRowIndex: -1` uses deliberately. The row lands at the TOP of the
9479
- // table rather than the gesture being dropped. Nothing here guards it yet.
9480
- // Task 14's hide-on-approach fix (see updateTableInsertBubbles() below)
9481
- // made this bubble reliably pressable, which widened the exposure of that
9482
- // deferred defect rather than narrowing it.
10270
+ // ⚠ RETRACTION, resolved in v3.4.0 (backlog #2). An earlier revision of
10271
+ // this comment went on to say that `afterRowIndex`, being a plain
10272
+ // integer, stays valid across that swap. Ruling T3-9 measured the
10273
+ // opposite: `afterRowIndex` is set by updateTableInsertBubbles() at
10274
+ // HOVER time, which can be arbitrarily earlier than this click — the
10275
+ // table's row count can shrink in between (its own delete-row, an undo,
10276
+ // anything) without a fresh mousemove ever repositioning the bubble to
10277
+ // notice. `tableIdentityOf()`'s row-count component (used by
10278
+ // ensureTableBurstOpen()'s own detached-table guard below) does NOT
10279
+ // catch this: identity is captured from `tableEl` at THIS click, in the
10280
+ // same synchronous prefix as `afterRowIndex` was just read above — it
10281
+ // always matches whatever the table looks like right now, so a handover
10282
+ // whose SOURCE the row count came from is unrelated to this table passes
10283
+ // it clean. `bodyRowsOf(liveTableEl)[afterRowIndex]` is the only place
10284
+ // that can see the stale index was for a row that no longer exists — a
10285
+ // naive `afterRow` falsy check does not distinguish "no row after this
10286
+ // one" (afterRowIndex: -1, `null` deliberately) from "this index no
10287
+ // longer names anything" (`undefined`), and insertRow() treats both
10288
+ // identically: `tbody.insertBefore(newRow, tbody.firstChild)`, i.e. the
10289
+ // row lands at the TOP of the table instead of the gesture being
10290
+ // dropped. Task 14's hide-on-approach fix (see updateTableInsertBubbles()
10291
+ // below) made this bubble reliably pressable, which widened the exposure
10292
+ // of this deferred defect rather than narrowing it.
9483
10293
  const liveTableEl = await ensureTableBurstOpen(tableEl);
9484
10294
  if (!liveTableEl) return;
9485
10295
  const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
10296
+ // afterRowIndex >= 0 names a SPECIFIC row that must still exist on the
10297
+ // live table; afterRowIndex === -1 deliberately produces a `null`
10298
+ // afterRow (the header-boundary/first-body-row case) and must NOT be
10299
+ // caught here. Same policy as performRowDrop()/runDeleteRow() above:
10300
+ // can't relocate it on the live table -> drop the gesture and say so,
10301
+ // never guess.
10302
+ if (afterRowIndex >= 0 && !afterRow) {
10303
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
10304
+ return;
10305
+ }
9486
10306
  currentBurst.history.snap('insert-row-pre');
9487
10307
  insertRow(liveTableEl, afterRow);
9488
10308
  armNewTableCells(liveTableEl);
@@ -9816,9 +10636,32 @@
9816
10636
  // ~104px to ~60px, which puts the menu under the toolbar while the grip that
9817
10637
  // opens it is still comfortably clickable. Both numbers were measured on
9818
10638
  // this branch, on either side of that change.
9819
- function placeTeMenuAboveCell(r) {
9820
- const topInset = parseFloat(getComputedStyle(document.documentElement)
10639
+ /**
10640
+ * The y a viewport-anchored affordance may not go above.
10641
+ *
10642
+ * The toolbar owns the top of the viewport and it owns it at `z-index: 101`,
10643
+ * which is above every floating thing this file positions — so an affordance
10644
+ * placed at a smaller `top` is not merely close to the toolbar, it is UNDER
10645
+ * it: painted, hit-tested and clicked by the bar instead. `--ed-toolbar-h` is
10646
+ * the height the bar reserves, and it is the floor `.ed-te-menu` and
10647
+ * `.ed-seltb` already use; v3.4.0 batch 1 moved `.ed-conflict` to the same
10648
+ * value for the same reason.
10649
+ *
10650
+ * It is a FUNCTION and not a copy of the number in three places because the
10651
+ * number was copied in three places: `.ed-wave-edit-btn` floored at `4`
10652
+ * instead, and measured at 1200x800 with a 180px diagram scrolled to
10653
+ * `r.top = -60` the button painted at `top: 4`, `elementFromPoint` on its own
10654
+ * centre returned `DIV.ed-toolbar`, and a real click left the editor closed —
10655
+ * with no keyboard entry to fall back on, that scroll position had no way in
10656
+ * at all. The next affordance reads this instead of rediscovering it.
10657
+ */
10658
+ function toolbarTopInset() {
10659
+ return parseFloat(getComputedStyle(document.documentElement)
9821
10660
  .getPropertyValue('--ed-toolbar-h')) || 0;
10661
+ }
10662
+
10663
+ function placeTeMenuAboveCell(r) {
10664
+ const topInset = toolbarTopInset();
9822
10665
  let top = r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX;
9823
10666
  if (top < topInset) top = r.bottom + TE_MENU_GAP_PX;
9824
10667
  teEdgeMenu.style.top = top + 'px';
@@ -10390,21 +11233,82 @@
10390
11233
  // from table-leave, burst-end, and drag-start elsewhere) already covers
10391
11234
  // every path that actually needs to clear it.
10392
11235
  //
10393
- // ⚠ KNOWN GAP, deferred by Ruling T14-5 to v3.4.0: the list below names the
10394
- // two grips and NOT '.ed-tb-insert', so the hover-insert + bubble — which
10395
- // is the same kind of `position: fixed` overlay painted over the same band
10396
- // — falls through to the branches beneath and takes the ⠿ grips down while
10397
- // the pointer stands on it. The reason it was not closed here is that
10398
- // widening this test also widens the early return's setGutterKeep() call,
10399
- // whose behaviour on the + bubble is unmeasured. A reader of this function
10400
- // should know it is knowingly incomplete rather than infer the list is the
10401
- // whole set.
10402
- if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
11236
+ // v3.4.0 (backlog #11, closing Ruling T14-5's deferral): the list below
11237
+ // now also names '.ed-tb-insert' — the hover-insert + bubble is the same
11238
+ // kind of `position: fixed` overlay painted over the same band as the two
11239
+ // grips, and without this the pointer standing on it fell through to the
11240
+ // branches beneath and took the ⠿ grips down (hideTableGrips() nulling
11241
+ // gripRowTableEl/gripRowEl/gripColTableEl/gripColIndex, not just hiding
11242
+ // the DOM).
11243
+ //
11244
+ // MEASURED effect on setGutterKeep(), which T14-5 left unmeasured: on a
11245
+ // 2-row/2-col fixture, hovering the header row raised the row grip at
11246
+ // rect {left:402, top:175.7, right:422, bottom:203.7}. The row-insert
11247
+ // bubble for "after the header row" rendered at {left:403, top:199.4,
11248
+ // right:421, bottom:217.4} — the bubble's OWN vertical centre (208.4)
11249
+ // already sits 0.6px below pointInRowGripZone()'s padded zone (bottom
11250
+ // 207.7 = grip bottom + the 4px pad), so a pointer landing there hit the
11251
+ // "not in zone" branch and called hideTableGrips() even before this fix.
11252
+ // Only the bubble's own top ~8px sliver (199.4-207.7) actually overlaps
11253
+ // the zone. Review round 2, M2: that 8.4px is this fixture's number, not
11254
+ // a constant — the general form (derived from the row grip's fixed
11255
+ // CSS height 28px, vertically centred on its own row per
11256
+ // buildTableGrip()/the row-grip positioning above, TE_GRIP_ZONE_PAD_PX=4,
11257
+ // and the bubble's own half-height 9px) is
11258
+ // `overlap = max(0, 27 - headerRowHeight / 2)` for any header row
11259
+ // taller than 18px (shorter than that and the whole 18px bubble sits
11260
+ // inside the zone — unreachable in practice, since padding alone
11261
+ // ('th, td { padding: 7px 14px }') already puts a real header row above
11262
+ // it). It hits 0 — no overlap at all — once the header row is >=54px
11263
+ // tall. This fixture's 37.3px header row gives 27 - 18.6 = 8.4, matching
11264
+ // the measured number above.
11265
+ //
11266
+ // The column-insert bubble does NOT overlap the row grip's
11267
+ // zone on the fixture measured above (x=[884.6, 902.6] against the
11268
+ // grip's padded x=[398,426]) — review round 2 corrected an earlier draft
11269
+ // of this comment that called that "structural", i.e. impossible in
11270
+ // general. It is not: the column bubble sits at `cellRight - half`
11271
+ // (half = TB_BUBBLE_SIZE/2 = 9), so it overlaps the row grip's own
11272
+ // padded zone (right edge at tableLeft + TE_GRIP_ZONE_PAD_PX = tableLeft
11273
+ // + 14, see pointInRowGripZone()) whenever `cellRight <= tableLeft + 23`.
11274
+ // The FIRST column is the only one close enough to matter, and its
11275
+ // MINIMUM width from `th, td { padding: 7px 14px; border: 1px solid … }`
11276
+ // alone (an empty or near-empty first cell) is ~30px — a 7px margin
11277
+ // above the 23px threshold, not a structural gap. That margin is a CSS
11278
+ // padding constant, not a geometric guarantee, and this repo's own
11279
+ // `table { overflow-x: auto }` can erode it directly: scrolling a wide
11280
+ // table's inner content moves `cellRect.right` toward (and, scrolled far
11281
+ // enough, past) `tableRect.left` while the row grip stays anchored to
11282
+ // the table's own (unscrolled) left edge, so the same overlap this
11283
+ // function already tolerates for the row-insert bubble becomes reachable
11284
+ // for the column-insert bubble too on a sufficiently scrolled table. The
11285
+ // BEHAVIOUR conclusion is unaffected either way — that path's delta is
11286
+ // still only the same cosmetic keep-lit loss described above, since
11287
+ // '.ed-tb-insert' never matches '.ed-te-grip-row' regardless of which
11288
+ // bubble the pointer is on — only the "never" in this comment was wrong.
11289
+ //
11290
+ // So the only real change is: for the narrow case where (a) the header
11291
+ // row's own grip is the one currently shown AND (b) the pointer is in
11292
+ // that ~8px sliver of the header row's insert bubble, the old fallback
11293
+ // branch below (pointInRowGripZone()/pointInColGripZone(), reached by
11294
+ // NOT matching this guard) could call setGutterKeep(headerGripBlock())
11295
+ // and light the gutter; the widened guard here calls setGutterKeep(null)
11296
+ // unconditionally instead (same as the existing column-grip branch),
11297
+ // because '.ed-tb-insert' never matches '.ed-te-grip-row'. The ⠿'s
11298
+ // keep-lit assist is not applied while the pointer sits in that sliver.
11299
+ // Accepted: it is a purely cosmetic loss (the block's own :hover keeps
11300
+ // the ⠿ visible whenever the pointer is actually over the block; keep-lit
11301
+ // only covers the pointer being OUTSIDE it) confined to an ~8px band on
11302
+ // one bubble, against hideTableGrips() tearing down all four module-level
11303
+ // refs and both grips' DOM visibility on every approach to any bubble —
11304
+ // the defect this task exists to close.
11305
+ if (target && target.closest &&
11306
+ (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col') || target.closest('.ed-tb-insert'))) {
10403
11307
  // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
10404
11308
  // Testing the hit rather than reusing the branch condition matters —
10405
- // the column grip sits above the table's top border, nowhere near the
10406
- // ⠿, and lighting the gutter from there is the behaviour change this
10407
- // fix is scoped to avoid.
11309
+ // the column grip (and, as of v3.4.0, the insert bubble) sits nowhere
11310
+ // near a position lighting the gutter should follow, and lighting it
11311
+ // from there is the behaviour change this fix is scoped to avoid.
10408
11312
  setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
10409
11313
  return;
10410
11314
  }
@@ -10715,13 +11619,47 @@
10715
11619
  // 吃掉之後沒有人會把焦點放回去,document.activeElement 會落到 <body>,
10716
11620
  // 於是 keydown 走不到 handleTableCellKeydown,Ctrl+Z 會落到全域 undo()
10717
11621
  // 而先 commit 再退。用序位重新解析目標格並真的 focus。
11622
+ // v3.4.0 §2 (measured, not guessed — see the two call sites' own comment
11623
+ // below for the full chain): by the time this runs, BOTH callers' drags
11624
+ // already have SOME cell focused — either the burst was already open
11625
+ // (the user had genuinely clicked/edited a cell themselves;
11626
+ // ensureTableBurstOpen() early-returns without calling focus() at all in
11627
+ // that case) or ensureTableBurstOpen() just opened a fresh one via its
11628
+ // own `forDrag` branch (which DOES call focus(), on the first body
11629
+ // cell). Either way, a plain focus() here was re-focusing that SAME cell
11630
+ // a second time — a stack-traced probe caught exactly that: two focus()
11631
+ // calls on the identical element, the second one (this one) with no
11632
+ // options, immediately followed by the page scrolling. `preventScroll`
11633
+ // stops that second, redundant scroll without changing which cell ends
11634
+ // up focused.
11635
+ // v3.4.0 §2: `cellIndex < 0`'s fallback used to be `cells[0]` — the
11636
+ // header cell — inherited from before this function had a `forDrag`-style
11637
+ // "avoid the header" policy anywhere. It stayed reachable in theory (a
11638
+ // negative index means "no cell to re-resolve"), but the row/col-drop
11639
+ // stale-index defect fixed below (both call sites now re-resolve against
11640
+ // a rebuild-stable coordinate rather than a cell reference/ordinal that
11641
+ // may have moved) removes every realistic path that could still produce
11642
+ // one here: a drag never deletes the active row/cell, only reorders it,
11643
+ // so a valid `activeCellEl` captured before the drop always resolves to
11644
+ // SOME live cell afterward. Changed anyway, defensively, since both of
11645
+ // this function's only two callers are drag paths (same fact the
11646
+ // `forDrag` policy above is built on): prefer the first BODY cell over
11647
+ // the header, same as `ensureTableBurstOpen()`'s own `forDrag` pick — a
11648
+ // drag landing on the header is exactly the symptom this whole task
11649
+ // fixed, so a fallback that still could is worth closing even if nothing
11650
+ // currently reaches it. Falls through to `cells[0]` only when the table
11651
+ // has no body row at all; `if (!cell) return` below still means "don't
11652
+ // focus anything" for the fully-empty case, covering that alternative
11653
+ // too.
10718
11654
  function restoreTableFocus(tableEl, cellIndex) {
10719
11655
  const cells = tableCellsOf(tableEl);
10720
- const cell = cells[cellIndex >= 0 ? Math.min(cellIndex, cells.length - 1) : 0];
11656
+ const cell = cellIndex >= 0
11657
+ ? cells[Math.min(cellIndex, cells.length - 1)]
11658
+ : (cells.find((c) => c.tagName !== 'TH') || cells[0]);
10721
11659
  if (!cell) return;
10722
11660
  if (currentBurst && currentBurst.blockType === 'table') currentBurst.activeCellEl = cell;
10723
11661
  selToolbarEditEl = cell;
10724
- cell.focus();
11662
+ cell.focus({ preventScroll: true });
10725
11663
  placeCaretAtEnd(cell);
10726
11664
  }
10727
11665
 
@@ -10730,7 +11668,11 @@
10730
11668
  // 可能 resolve 掉別的 block 的 dirty burst 並換掉整片 .content,所以
10731
11669
  // 先轉成 allRowsOf() 的 ordinal,之後在 live table 上重新定位。
10732
11670
  const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
10733
- const liveTableEl = await ensureTableBurstOpen(tableEl);
11671
+ // v3.4.0 §2: `forDrag=true` — see ensureTableBurstOpen()'s own comment.
11672
+ // A row drag never had a focused cell of its own, so if this call is
11673
+ // the one that opens the burst, it must neither scroll the page nor
11674
+ // land on the header cell.
11675
+ const liveTableEl = await ensureTableBurstOpen(tableEl, true);
10734
11676
  if (!liveTableEl) return;
10735
11677
  const liveRows = allRowsOf(liveTableEl);
10736
11678
  if (rowIndex < 0 || !liveRows[rowIndex]) return;
@@ -10747,8 +11689,55 @@
10747
11689
 
10748
11690
  currentBurst.history.snap('drag-row-pre');
10749
11691
  const aligns = columnAlignsOf(liveTableEl);
10750
- const activeIndex = (currentBurst && currentBurst.activeCellEl)
10751
- ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
11692
+ // v3.4.0 §2(量出來的,不是猜的):這裡曾經寫過
11693
+ // `if (activeIndex >= 0) restoreTableFocus(...)`,想法是拖曳沒點過任何
11694
+ // 儲存格時 activeIndex 應該是 -1、藉此跳過這次呼叫。實測證明那個模型
11695
+ // 錯了——`activeIndex` 在這裡結構上永遠 >= 0:ensureTableBurstOpen()
11696
+ // 只有在 currentBurst 確實是這個表格時才會回傳非 null,而
11697
+ // `currentBurst.activeCellEl` 在 startTableBurst() 建立 currentBurst
11698
+ // 的同一個賦值式裡就一定被設好(不管 burst 是本來就開著、還是這次
11699
+ // ensureTableBurstOpen() 的 forDrag 分支剛開的)。所以「用 index 判斷
11700
+ // 要不要呼叫 restoreTableFocus()」這條路走不通,不要再試。
11701
+ //
11702
+ // 真正的捲動來源是 restoreTableFocus() 自己那次 focus():它一定會把
11703
+ // ensureTableBurstOpen() 剛剛(forDrag、preventScroll、挑 body 儲存格)
11704
+ // 已經對好焦的那顆儲存格,重新 focus 一次——用一支外接的 stack-trace
11705
+ // 探針量到兩次 focus() 打在同一顆元素上,第二次沒帶 preventScroll,
11706
+ // 緊接著就是那次把畫面拉回去的捲動。修法落在 restoreTableFocus() 自己
11707
+ // 身上(它的 cell.focus() 現在帶 { preventScroll: true }),這裡維持
11708
+ // 無條件呼叫。
11709
+ //
11710
+ // 另一個既存缺陷(跟以上兩件事無關,重排「之前」量、「之後」用同一個
11711
+ // 序位):原本 `activeIndex` 在這裡(rebuildTableSections() 之前)用
11712
+ // `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算好,
11713
+ // 卻在下面 restoreTableFocus() 裡對「重排之後」的 cell 清單使用——重排
11714
+ // 只要讓被拖那列插到 activeCellEl 那列前面,中間隔的 cell 數就變了,
11715
+ // 舊序位對到的會是別的儲存格(使用者正在打字那格,焦點卻被丟到別的
11716
+ // TD,接下來每一鍵都寫進錯的儲存格)。用一支 stack-trace 探針量過:
11717
+ // rebuildTableSections() 本身只搬 <tr>(appendChild,never 重建),但
11718
+ // retagCell() 在該列跨越表頭/本文邊界(被升格成表頭或被降成本文)時
11719
+ // **會**造出全新的 <td>/<th>(複製屬性、不是同一顆物件)——用 JS
11720
+ // expando 屬性(不是 HTML attribute,因為 attribute 會被複製過去,測
11721
+ // 不出是不是同一顆)量過:不跨表頭時標記存活,跨表頭時標記消失。
11722
+ // <tr> 元素本身兩種情況都不變(只有 appendChild 搬過),所以「列+欄」
11723
+ // 這組座標在重排前後都穩,序位不穩。改成:重排前記下 activeRowEl(那
11724
+ // 顆 <tr>)與 activeColIdx(欄序,colIndexOf() 已有);重排、
11725
+ // armNewTableCells() 都跑完之後,用 `activeRowEl.cells[activeColIdx]`
11726
+ // 重新取得那顆儲存格(不管 retagCell() 換過沒有,同一列同一欄序就是
11727
+ // 使用者原本在打字的那顆),再轉回 restoreTableFocus() 要的序位。
11728
+ // Review fix (M2): `document.body.contains()` first — same guard
11729
+ // refocusAwayFromColumn()/refocusAwayFromRow() above both use before
11730
+ // their own colIndexOf() call. `activeCellEl` DETACHED (not merely
11731
+ // stale) is a state this file already treats as reachable there;
11732
+ // colIndexOf() reads `cellEl.parentElement.cells`, which throws on a
11733
+ // detached node whose parentElement is null — unlike the old
11734
+ // `tableCellsOf(...).indexOf(...)` this replaced, which just returned
11735
+ // -1 for a detached cell. Thrown here (after `history.snap('drag-row-pre')`,
11736
+ // before the reorder) would silently drop the whole gesture.
11737
+ const activeCellBefore = (currentBurst && currentBurst.activeCellEl &&
11738
+ document.body.contains(currentBurst.activeCellEl)) ? currentBurst.activeCellEl : null;
11739
+ const activeRowEl = activeCellBefore ? activeCellBefore.parentElement : null;
11740
+ const activeColIdx = activeCellBefore ? colIndexOf(activeCellBefore) : -1;
10752
11741
  suppressTableFocusout = true;
10753
11742
  try {
10754
11743
  rebuildTableSections(liveTableEl, order, aligns);
@@ -10756,6 +11745,8 @@
10756
11745
  suppressTableFocusout = false;
10757
11746
  }
10758
11747
  armNewTableCells(liveTableEl);
11748
+ const restoredCell = (activeRowEl && activeColIdx >= 0) ? activeRowEl.cells[activeColIdx] : null;
11749
+ const activeIndex = restoredCell ? tableCellsOf(liveTableEl).indexOf(restoredCell) : -1;
10759
11750
  restoreTableFocus(liveTableEl, activeIndex);
10760
11751
  // 插入路徑早就這樣做了,drop 路徑一直沒有:不清的話 grip 還釘在舊
10761
11752
  // 座標、teMenuColIndex 指向已經換位的欄。
@@ -10792,11 +11783,10 @@
10792
11783
  }
10793
11784
 
10794
11785
  async function performColDrop(tableEl, fromIndex, toIndex) {
10795
- const liveTableEl = await ensureTableBurstOpen(tableEl);
11786
+ // v3.4.0 §2: `forDrag=true` — same reasoning as performRowDrop() above.
11787
+ const liveTableEl = await ensureTableBurstOpen(tableEl, true);
10796
11788
  if (!liveTableEl) return;
10797
11789
  if (toIndex === fromIndex || toIndex === fromIndex + 1) return; // 原地放回
10798
- const activeIndex = (currentBurst && currentBurst.activeCellEl)
10799
- ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
10800
11790
  // 短列必須讓整個操作放棄,不能只跳過那一列(final review M4):原本
10801
11791
  // `if (!moved) return;` 在 forEach 裡面,短列會被略過、其他列照搬 —— 結果
10802
11792
  // 是欄位彼此錯位,而每一列的 cell 數量都跟原本一樣,ragged-table guard
@@ -10816,6 +11806,27 @@
10816
11806
  } finally {
10817
11807
  suppressTableFocusout = false;
10818
11808
  }
11809
+ // v3.4.0 §2(量出來的,不是猜的——完整說明見 performRowDrop() 對應處的
11810
+ // 同一段註解):這裡曾經寫過 `if (activeIndex >= 0) restoreTableFocus(...)`,
11811
+ // 想靠 index 擋掉這次呼叫,但 `activeIndex` 在這裡結構上永遠 >= 0
11812
+ // (ensureTableBurstOpen() 只有在 currentBurst 確實是這個表格時才回傳
11813
+ // 非 null,而 currentBurst.activeCellEl 在 startTableBurst() 建立
11814
+ // currentBurst 的同一個賦值式裡就一定被設好)——這條路走不通,不要
11815
+ // 再試。真正的捲動來源是 restoreTableFocus() 自己那次 focus(),修法
11816
+ // 落在它自己身上(`{ preventScroll: true }`),這裡維持無條件呼叫。
11817
+ //
11818
+ // 另一個既存缺陷,跟列拖曳同根、但失效方式不同(欄重排動的是「同一列
11819
+ // 裡 cell 的順序」,不是列本身):這裡原本也是在上面重排「之前」用
11820
+ // `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算
11821
+ // `activeIndex`,重排之後才用——欄一旦搬過,每一列裡 cell 的順序都變
11822
+ // 了,`tableCellsOf()`(跨全表攤平)算出來的序位一樣是舊的。用同一支
11823
+ // 探針量過:欄重排的 forEach 是 `row.appendChild(c)`,對已經是那個
11824
+ // row 子節點的 cell 只是搬動、從不建立新節點(跟列拖曳的 retagCell()
11825
+ // 不同,欄拖曳完全不牽涉 TH/TD 轉換),所以不需要「列+欄」那組座標
11826
+ // ——重排後直接用同一顆 `currentBurst.activeCellEl` 物件重算一次
11827
+ // indexOf() 就對了,物件本身沒被換過。
11828
+ const activeIndex = (currentBurst && currentBurst.activeCellEl)
11829
+ ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
10819
11830
  restoreTableFocus(liveTableEl, activeIndex);
10820
11831
  hideTableGrips();
10821
11832
  hideTableEdgeMenu();
@@ -11661,6 +12672,12 @@
11661
12672
  // next one.
11662
12673
  blockSelClickSuppressed = false;
11663
12674
  if (blockSelDrag) endBlockSelDrag();
12675
+ // Every defensive teardown above still runs — they are no-ops that must not
12676
+ // be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
12677
+ // already makes this unreachable (measured: a Shift+press-drag on the canvas
12678
+ // and a press-drag on the backdrop both leave the selection null); it is
12679
+ // here so that stays true if the backdrop ever stops being `inset: 0`.
12680
+ if (waveEditorIsModal()) return;
11664
12681
  const hit = hitTestGrip(e.target);
11665
12682
  // A click on a DIFFERENT zone (or entirely outside any zone) dismisses
11666
12683
  // whatever menu is already open, same "any other click closes the ⠿
@@ -12440,6 +13457,15 @@
12440
13457
  snapBurstIfActive(root, 'mark');
12441
13458
  return false; // unwrapped in place — no modal ever opened
12442
13459
  }
13460
+ // v3.4.0 / v3.3.0 backlog 4: applyMarkToggle() guards its wrap branch with
13461
+ // trimRangeToText() before wrapping; this new-link branch never had the
13462
+ // matching guard. A whitespace-only selection (the space between two
13463
+ // words, say) reached window.prompt() and, on accept, extractRangeInto()
13464
+ // wrapped that bare space in an <a> — an anchor whose entire content was
13465
+ // one space, written straight to disk. Trim first: an all-whitespace
13466
+ // range trims to nothing and trimRangeToText() returns false, so bail
13467
+ // before ever opening the prompt.
13468
+ if (!trimRangeToText(range)) return false;
12443
13469
  const url = window.prompt('連結網址:', 'https://');
12444
13470
  if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
12445
13471
  snapBurstIfActive(root, 'mark-pre');
@@ -12539,8 +13565,7 @@
12539
13565
  const rect = range.getBoundingClientRect();
12540
13566
  // v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
12541
13567
  // 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
12542
- const topInset = parseFloat(getComputedStyle(document.documentElement)
12543
- .getPropertyValue('--ed-toolbar-h')) || 0;
13568
+ const topInset = toolbarTopInset();
12544
13569
  if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
12545
13570
  rect.right <= 0 || rect.left >= window.innerWidth) {
12546
13571
  // Task 16: of the exits that take this bar down, this is the one that
@@ -12573,8 +13598,25 @@
12573
13598
  // the raw textarea directly on click, no bar/menu step) — see
12574
13599
  // armEditables() above. What's left to route here is: the lightbox
12575
13600
  // exclusion, the ⠿ handle/menu, and opening a degraded block's raw editor.
13601
+ // Fix round 3 (re-review2 Q2): `.drawio` belongs here for the same reason
13602
+ // every other entry does — it is a lightbox target in the reader runtime
13603
+ // (`LIGHTBOX_TARGETS` in lib/md2doc.js), and a click that opens the lightbox
13604
+ // must not ALSO be routed as a click on the block.
13605
+ //
13606
+ // MEASURED before adding it, in a real editor page with a diagram in an
13607
+ // armed paragraph and the same diagram in a DEGRADED table:
13608
+ // paragraph lightbox flex/visible, `.ed-raw` count 0, activeElement BODY
13609
+ // (the armed branch already returns above — no consequence)
13610
+ // table lightbox flex/visible, `.ed-raw` count 1,
13611
+ // activeElement TEXTAREA.ed-raw
13612
+ // i.e. on a degraded block the raw markdown editor silently opened BEHIND
13613
+ // the lightbox. With `.drawio` listed, the diagram behaves exactly like the
13614
+ // `img` / `.mermaid` / `.graphviz` / WaveDrom entries beside it: clicking
13615
+ // the picture opens the picture and nothing else. No capability is lost
13616
+ // that those four do not already lack — the block's source is still one
13617
+ // click away anywhere else in the block.
12576
13618
  const ED_LIGHTBOX_TARGETS =
12577
- 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
13619
+ 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
12578
13620
 
12579
13621
  // Single delegated listener, wired once at the bottom of this file. Async
12580
13622
  // because clicking another block (or outside any block) while some
@@ -12687,6 +13729,14 @@
12687
13729
  // would also fall through to "clicked outside any block" below and
12688
13730
  // fire an unwanted switchAwayFrom() right after the menu just opened.
12689
13731
  if (e.target.closest('.ed-te-grip')) return;
13732
+ // v3.4.0 batch3 Task 6: while the wave editor is the modal surface,
13733
+ // nothing behind it takes clicks. This replaced a pair of `closest()`
13734
+ // exclusions for the overlay and its entry button: those were right about
13735
+ // the two cases they named — a click on either used to fall through to
13736
+ // "clicked outside any block" and fire switchAwayFrom() on the document
13737
+ // behind the modal — and wrong about being a list at all.
13738
+ if (waveEditorIsModal()) return;
13739
+ if (e.target.closest('.ed-wave-edit-btn')) return;
12690
13740
  // Task 9: task-list checkbox toggle. The .ed-li-check span is
12691
13741
  // non-focusable chrome — no per-node listener, routed here by
12692
13742
  // delegation. Gate BEFORE mutation (same contract as the structural
@@ -12828,6 +13878,39 @@
12828
13878
 
12829
13879
  // ── save ───────────────────────────────────────────────────────────────
12830
13880
  async function save() {
13881
+ // fix 3: the receipt for the bytes about to leave, taken BEFORE the request
13882
+ // and not after it. `markSaved()` used to read the stack depth when the
13883
+ // RESPONSE landed, and a save round trip is hundreds of milliseconds —
13884
+ // easily long enough to press Ctrl+Z in. MEASURED: type, commit, Ctrl+S,
13885
+ // Ctrl+Z inside the round trip — the 200 marked the post-undo depth as the
13886
+ // saved point, so the title dot went out, the save button greyed,
13887
+ // `beforeunload` stopped firing, AND `mtimeMs` had just advanced so the
13888
+ // conflict check would not catch it either. All three nets failed at once,
13889
+ // and the tab closed leaving on disk an edit the user had explicitly undone.
13890
+ // See UndoStack.saveToken()/markSaved() for why the receipt carries an op
13891
+ // identity and not just a depth.
13892
+ const token = stack.saveToken();
13893
+ //
13894
+ // ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
13895
+ // that is a correction of something this file briefly carried. The premise
13896
+ // for dropping an "older" reply was that a later request's bytes are what
13897
+ // disk ends up holding. That premise is FALSE: the server 409s whenever
13898
+ // `baseMtimeMs` does not match the file (server.js's /api/save), and
13899
+ // `mtimeMs` has exactly one writer — the 200 branch below. So a second save
13900
+ // sent while the first is still in flight necessarily carries a stale
13901
+ // baseline, necessarily 409s, and necessarily writes nothing. The FIRST
13902
+ // reply is the one that describes what is on disk, and dropping it is the
13903
+ // defect, not the fix. MEASURED with the guard in place: type, Ctrl+S,
13904
+ // Ctrl+Z, Ctrl+S — the first 200 was discarded, `markSaved()` never ran, and
13905
+ // the document read CLEAN with the edit still on disk; and without any undo
13906
+ // at all, two Ctrl+S in a row left `mtimeMs` stale forever, so every later
13907
+ // save 409'd and the only way out was the conflict banner's reload. An
13908
+ // exhaustive depth-6 sweep counted 48 settled under-warns with the guard and
13909
+ // 0 without it.
13910
+ //
13911
+ // The receipt above is what makes a late reply safe without any of that: it
13912
+ // describes the bytes it was taken for, so a reply that no longer matches
13913
+ // the timeline refuses itself.
12831
13914
  let res;
12832
13915
  try {
12833
13916
  res = await fetch('/api/save', {
@@ -12848,8 +13931,17 @@
12848
13931
  return;
12849
13932
  }
12850
13933
  mtimeMs = j.mtimeMs;
12851
- stack.markSaved();
13934
+ stack.markSaved(token);
12852
13935
  setDirty();
13936
+ // v3.4.0 §3: markSaved() just moved documentIsDirty() from true to
13937
+ // false, and the `save` button's own lit/grey state is read off
13938
+ // exactly that value (toolbarContext()'s `dirty` field) — without this
13939
+ // the button stays lit after a successful save until some UNRELATED
13940
+ // gesture happens to repaint the bar. Same repaint entry point
13941
+ // setDirty()'s own callers already share (updateToolbar() diffs its
13942
+ // own signature, so this is cheap on the common "toolbar was already
13943
+ // caught up" case).
13944
+ updateToolbar();
12853
13945
  return;
12854
13946
  }
12855
13947
  if (res.status === 409) {
@@ -12863,6 +13955,140 @@
12863
13955
  showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
12864
13956
  }
12865
13957
 
13958
+ // v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
13959
+ // Ctrl+S's own keydown handler already carries the full explanation (a
13960
+ // mid-burst keystroke has not reached `lines` yet, so save() must not run
13961
+ // before switchAwayFrom() does). The toolbar `save` button needs the
13962
+ // identical precondition, so both callers share this one function instead
13963
+ // of each spelling out `switchAwayFrom().then(...)` on its own — not a
13964
+ // copy of save()'s own body, just its one caller-side precondition named
13965
+ // once.
13966
+ async function commitThenSave() {
13967
+ const ok = await switchAwayFrom();
13968
+ if (ok) return save();
13969
+ }
13970
+
13971
+ // v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
13972
+ // may resolve (commit + re-render) a still-open burst before save() ever
13973
+ // runs, and that render is exactly the class of render that strands the
13974
+ // caret on BODY when the caret was sitting inside the block being
13975
+ // replaced — recorded, for undo/redo/image, in activateToolbarCursor()'s
13976
+ // own comment ("join them ONLY when the surface has an uncommitted
13977
+ // edit"), and unconditionally for quote/code/line (backlog #6, still
13978
+ // open). `save` must not become a fourth name on that list — and
13979
+ // deliberately does NOT reuse undo()/redo()'s own path to avoid it:
13980
+ // neither of them has any rescue at all (that gap is recorded as
13981
+ // deferred, not fixed), so there is nothing there worth copying.
13982
+ //
13983
+ // What captureCaretForSave() saves: which `.ed-block` the caret's FOCUS
13984
+ // end was in (`data-block-id` — stable across a same-shape commit, since
13985
+ // buildBlockMap() numbers every block by document order starting at 0 on
13986
+ // every render, and a text-only edit that neither adds nor removes a
13987
+ // block leaves every block's id exactly where it was) and how many
13988
+ // characters into that block's own rendered text that end sat (every text
13989
+ // node before it, concatenated — the same measure
13990
+ // `Range.toString().length` gives over a selectNodeContents() range
13991
+ // clipped to that point). restoreCaretForSave() reverses it: walk the
13992
+ // (possibly brand-new) block's text nodes until that many characters are
13993
+ // consumed, and collapse a Range there. It is a no-op whenever the caret
13994
+ // was never stranded in the first place (the common case — a clean
13995
+ // document, or a burst with nothing uncommitted, never touches the DOM at
13996
+ // all) — checked by asking whether focus is still on BODY afterwards, not
13997
+ // by trying to predict which of the paths above ran.
13998
+ //
13999
+ // Review C1: `sel.focusNode`/`focusOffset`, not `anchorNode`/
14000
+ // `anchorOffset`, and no `!sel.isCollapsed` bail-out. The first version
14001
+ // gave up on any non-collapsed selection entirely — MEASURED: type at the
14002
+ // end of a block without committing, extend the selection left with
14003
+ // Shift+ArrowLeft, click save — restoreCaretForSave() never ran at all
14004
+ // (captureCaretForSave() had already returned null), and the caret was
14005
+ // left exactly where quote/code/line leave it: on BODY, typing silently
14006
+ // discarded. That is backlog #6's own symptom, reached through a keyboard
14007
+ // path that is not remotely rare (fix a typo with Shift+Arrow, then Ctrl+
14008
+ // S/Alt+F10+Enter on `save`). A collapsed caret is only the special case
14009
+ // where anchor and focus already coincide; using focus and dropping the
14010
+ // guard restores BOTH cases through one path. Collapsing the RESTORED
14011
+ // caret at that single point (rather than reconstructing the whole
14012
+ // pre-click range) is deliberate — landing at the focus end, one keyboard
14013
+ // Shift+Arrow removed from BODY rather than on it, is what backlog #6
14014
+ // asks for; there is no saved anchor position left to rebuild a
14015
+ // non-collapsed selection FROM once the block has been re-rendered.
14016
+ function captureCaretForSave() {
14017
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
14018
+ if (!sel || sel.rangeCount === 0) return null;
14019
+ const focus = sel.focusNode;
14020
+ if (!focus) return null;
14021
+ const host = focus.nodeType === 1 ? focus : focus.parentElement;
14022
+ const blockEl = host && host.closest ? host.closest('.ed-block') : null;
14023
+ if (!blockEl) return null;
14024
+ const blockId = blockEl.getAttribute('data-block-id');
14025
+ if (blockId === null) return null;
14026
+ const measure = document.createRange();
14027
+ measure.selectNodeContents(blockEl);
14028
+ measure.setEnd(focus, sel.focusOffset);
14029
+ return { blockId: blockId, offset: measure.toString().length };
14030
+ }
14031
+
14032
+ function restoreCaretForSave(saved) {
14033
+ // Nothing to do when the caret was never stranded — leave whatever is
14034
+ // focused (including a genuinely different block the user clicked into
14035
+ // meanwhile) alone.
14036
+ if (!saved || document.activeElement !== document.body) return;
14037
+ const blockEl = contentEl.querySelector(
14038
+ '.ed-block[data-block-id="' + saved.blockId + '"]');
14039
+ // Review M1: `blockEl` is null whenever the commit added or removed
14040
+ // blocks (buildBlockMap() renumbers by document order, so `save`'s own
14041
+ // commit — a plain text edit — cannot do this, but a future caller of
14042
+ // captureCaretForSave()/restoreCaretForSave() might run them around
14043
+ // something structural). There is no corresponding block left to place
14044
+ // a caret in at all, so this silently leaves the caret on BODY — a
14045
+ // strict improvement over guessing at a different block, but not a full
14046
+ // rescue. Falling back to `document.body.focus()` would not help either
14047
+ // (BODY is not focusable), so this is left as a documented, narrower
14048
+ // gap rather than a fixed one.
14049
+ const surface = blockEl && blockContentEl(blockEl);
14050
+ if (!surface) return;
14051
+ const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
14052
+ let remaining = saved.offset;
14053
+ let node = walker.nextNode();
14054
+ let target = null;
14055
+ let targetOffset = 0;
14056
+ while (node) {
14057
+ if (remaining <= node.nodeValue.length) { target = node; targetOffset = remaining; break; }
14058
+ remaining -= node.nodeValue.length;
14059
+ node = walker.nextNode();
14060
+ }
14061
+ if (!target) { placeCaretAtEnd(surface); return; }
14062
+ const range = document.createRange();
14063
+ range.setStart(target, targetOffset);
14064
+ range.collapse(true);
14065
+ const sel = window.getSelection();
14066
+ sel.removeAllRanges();
14067
+ sel.addRange(range);
14068
+ }
14069
+
14070
+ // The one entry point the toolbar/keyboard-cursor dispatch calls — wraps
14071
+ // commitThenSave() with the capture/restore pair above. Ctrl+S itself is
14072
+ // untouched: it is not one of the three buttons backlog #6 names, driving it
14073
+ // was out of that task's scope, and wrapping it here too would risk
14074
+ // perturbing the Finding-1/Finding-3 Ctrl+S coverage this branch already has
14075
+ // for reasons that have nothing to do with the caret.
14076
+ //
14077
+ // fix 5 / R9: the sentence here used to say Ctrl+S "still calls
14078
+ // commitThenSave() directly". It does not and has not — the keydown branch
14079
+ // spells the same precondition out inline as
14080
+ // `switchAwayFrom().then((ok) => { if (ok) save(); })`, deliberately, because
14081
+ // that exact line is pinned verbatim by shortcutOrderChecks in
14082
+ // test/editor-client.test.js (its own comment there says so). The two
14083
+ // spellings are behaviourally identical; the claim about which function is
14084
+ // called was simply wrong, and a reader following it would go looking for a
14085
+ // call site that does not exist.
14086
+ async function saveViaToolbar() {
14087
+ const saved = captureCaretForSave();
14088
+ await commitThenSave();
14089
+ restoreCaretForSave(saved);
14090
+ }
14091
+
12866
14092
  // ── undo / redo ───────────────────────────────────────────────────────
12867
14093
  async function undo() {
12868
14094
  // T21 item 1: an Escape that discarded a burst leaves exactly one thing
@@ -12871,6 +14097,16 @@
12871
14097
  // answers false whenever there is no such thing (which is almost always),
12872
14098
  // and the ordinary document undo below runs untouched.
12873
14099
  if (restoreDiscardedBurst()) return;
14100
+ // v3.4.0 (Task 7 item 5): the raw-editor twin — see restoreDiscardedRawEdit()'s
14101
+ // own comment. Mutually exclusive with the burst stash above (see
14102
+ // discardedRawEdit's own comment), so checking both in sequence never
14103
+ // answers a stale one while the other substrate holds the fresher discard.
14104
+ if (await restoreDiscardedRawEdit()) return;
14105
+ // v3.4.0 batch3 Task 7: the wave twin. Third in the sequence for no reason
14106
+ // other than that it is the newest; all three are mutually exclusive by
14107
+ // construction (one editing session at a time) and each nulls its own slot
14108
+ // before deciding, so order cannot make a stale one answer.
14109
+ if (await restoreDiscardedWaveEdit()) return;
12874
14110
  // Resolve any open editor BEFORE the .content-replacing swap below —
12875
14111
  // see the `activeEditor` / switchAwayFrom() comments near its
12876
14112
  // declaration for why this is required (undo/redo silently detaching an
@@ -12944,12 +14180,44 @@
12944
14180
  }
12945
14181
  }
12946
14182
 
14183
+ // backlog #6 — the toolbar's own caret rescue for undo/redo/image, over
14184
+ // the SAME captureCaretForSave()/restoreCaretForSave() pair saveViaToolbar()
14185
+ // above uses, not a copy of it. activateToolbarCursor()'s own landing-note
14186
+ // comment records exactly when this bites: "undo, redo and image join
14187
+ // [quote/code/line, permanently stranded] ONLY when the surface has an
14188
+ // uncommitted edit — the action resolves that burst first, and the
14189
+ // re-render is what drops the caret." That resolve-then-render shape is
14190
+ // undo()/redo()'s own switchAwayFrom() call and pickAndInsertImage()'s own
14191
+ // — identical to what saveViaToolbar() wraps commitThenSave() around — so
14192
+ // the same capture-before/restore-after pair applies unchanged: capture
14193
+ // reads the CURRENT (possibly mid-edit) DOM before anything commits,
14194
+ // restore is a no-op whenever focus never left the DOM in the first place
14195
+ // (the common case, nothing dirty).
14196
+ //
14197
+ // Scoped to the TOOLBAR path ONLY, deliberately: Ctrl+Z/Ctrl+Y (the global
14198
+ // keydown handler further down) still call undo()/redo() directly, and a
14199
+ // focused burst's own Ctrl+Z is `burstUndo()` — an entirely different,
14200
+ // intra-burst mechanism that never reaches this file's undo()/redo() at
14201
+ // all. Widening either of those was out of this task's scope, the same
14202
+ // boundary saveViaToolbar()'s own comment draws around Ctrl+S.
14203
+ async function undoViaToolbar() {
14204
+ const saved = captureCaretForSave();
14205
+ await undo();
14206
+ restoreCaretForSave(saved);
14207
+ }
14208
+
14209
+ async function redoViaToolbar() {
14210
+ const saved = captureCaretForSave();
14211
+ await redo();
14212
+ restoreCaretForSave(saved);
14213
+ }
14214
+
12947
14215
  // ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
12948
14216
  // The version's headline feature: before this there was no toolbar at all,
12949
14217
  // and every editing affordance was hidden behind a hover gutter, a caret
12950
14218
  // position or a text selection. window.md2docToolbarModel (injected by
12951
14219
  // server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
12952
- // 22-button roster, the group order that decides separator placement, and
14220
+ // 23-button roster, the group order that decides separator placement, and
12953
14221
  // the whole active/disabled derivation; everything below is mounting and
12954
14222
  // wiring. Nothing here re-derives a button's state by hand — a second
12955
14223
  // opinion about when ⇥ is legal is exactly what the pure-module split
@@ -12976,6 +14244,9 @@
12976
14244
 
12977
14245
  const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
12978
14246
  const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
14247
+ // backlog #10 — see the keydown handler's own comment for why every
14248
+ // stray-key hand-back raises this rather than only some of them.
14249
+ const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
12979
14250
 
12980
14251
  // Extra visual classes for the three inline buttons whose own glyph is the
12981
14252
  // affordance (B is bold, I is italic, S is struck through) — the same
@@ -13155,6 +14426,11 @@
13155
14426
  // .ed-seltb is painted from the same measurement — see updateToolbar().
13156
14427
  marks: selectionMarkStates(sel),
13157
14428
  mode: docMode,
14429
+ // v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
14430
+ // above burstHasUncommittedEdit()'s own comment) is the SAME
14431
+ // expression setDirty() paints the title dot from — not a second copy
14432
+ // of it.
14433
+ dirty: documentIsDirty(),
13158
14434
  };
13159
14435
  }
13160
14436
 
@@ -13170,7 +14446,7 @@
13170
14446
  paintSelToolbarMarks(ctx.marks);
13171
14447
  const state = toolbarModel.deriveState(ctx);
13172
14448
  // selectionchange fires on every caret move, so the state is diffed
13173
- // before any DOM is written — 22 attribute writes per keystroke is a
14449
+ // before any DOM is written — 23 attribute writes per keystroke is a
13174
14450
  // cost with no matching benefit.
13175
14451
  const sig = JSON.stringify(state);
13176
14452
  if (sig === toolbarStateSig) return;
@@ -13181,7 +14457,7 @@
13181
14457
  if (!st || !btn) return;
13182
14458
  btn.disabled = !!st.disabled;
13183
14459
  // aria-pressed ONLY on the buttons that can actually be pressed-in.
13184
- // Writing it to all 22 made a screen reader announce `undo`, `image`
14460
+ // Writing it to all 23 made a screen reader announce `undo`, `image`
13185
14461
  // and `table` as toggle buttons stuck in the "not pressed" state,
13186
14462
  // which is a lie about what they do.
13187
14463
  if (TOOLBAR_TOGGLE_IDS.has(id)) {
@@ -13225,11 +14501,15 @@
13225
14501
  // The one-pixel margin is a deliberate cushion and NOT a measured
13226
14502
  // requirement — say so rather than inventing a reason for it. Driven at 900
13227
14503
  // and at 420 px wide, the largest scrollLeft the bar accepts came back equal
13228
- // to scrollWidth minus clientWidth to the pixel (75 and 555), so a strict
13229
- // comparison would have been correct at both. The cushion is there because
13230
- // scrollWidth is reported as a whole number over a row whose own width is
13231
- // not, and what it costs when the two agree is that the gradient goes out a
13232
- // pixel before the travel ends.
14504
+ // to scrollWidth minus clientWidth to the pixel (42 and 522 — re-measured
14505
+ // for v3.4.0 §3, a one-off puppeteer script, not assumed: .ed-toolbar's gap
14506
+ // 6px -> 3px changed scrollWidth at EVERY width, not just the 420px one a
14507
+ // different scenario already re-pinned, so 900px needed its own fresh
14508
+ // number rather than inheriting the old 75), so a strict comparison would
14509
+ // have been correct at both. The cushion is there because scrollWidth is
14510
+ // reported as a whole number over a row whose own width is not, and what it
14511
+ // costs when the two agree is that the gradient goes out a pixel before the
14512
+ // travel ends.
13233
14513
  function paintToolbarOverflow() {
13234
14514
  if (!toolbarEl) return;
13235
14515
  const max = toolbarEl.scrollWidth - toolbarEl.clientWidth;
@@ -13335,6 +14615,28 @@
13335
14615
  }
13336
14616
  }
13337
14617
 
14618
+ // backlog #10: the ARIA toolbar pattern's own "jump to the edge" keys —
14619
+ // Home to the first enabled button, End to the last. `edge` is -1 for
14620
+ // Home, +1 for End. Unlike moveToolbarCursor()'s relative step, this walks
14621
+ // from the bar's own edge inward rather than from wherever the cursor
14622
+ // currently sits, so it always lands on the true first/last enabled
14623
+ // button regardless of where the cursor started.
14624
+ function moveToolbarCursorToEdge(edge) {
14625
+ const btns = toolbarRovingBtns();
14626
+ if (!btns.length) return;
14627
+ const step = edge < 0 ? 1 : -1;
14628
+ let i = edge < 0 ? 0 : btns.length - 1;
14629
+ for (let count = 0; count < btns.length; count++) {
14630
+ if (!btns[i].disabled) {
14631
+ toolbarRovingIndex = i;
14632
+ paintToolbarRoving();
14633
+ revealToolbarCursor(btns[i]);
14634
+ return;
14635
+ }
14636
+ i += step;
14637
+ }
14638
+ }
14639
+
13338
14640
  // No "already in the mode" guard, because the key branch cannot deliver one:
13339
14641
  // while the mode is on, the entry chord's own key is not one of the keys the
13340
14642
  // mode names, so it takes the hand-back path and ends the mode instead. The
@@ -13353,37 +14655,107 @@
13353
14655
  return true;
13354
14656
  }
13355
14657
 
14658
+ // Task 9 review I2: real DOM focus can land on a genuine standalone
14659
+ // control — `#doc-search-input` (backlog #9's own fix moves focus there
14660
+ // on purpose) or a `textarea.ed-raw` (backlog #6's quote/code/line fix
14661
+ // opens one) — while toolbarRovingIndex is STILL >= 0, because neither
14662
+ // fix touches it (the F12 cursor is virtual by design: "no toolbar
14663
+ // button ever takes DOM focus", see F12's own load-bearing comment
14664
+ // above). MEASURED (review): with focus in `#doc-search-input` holding
14665
+ // 'needle' at selectionStart 6, `Home`/`End`/`ArrowLeft` were ALL still
14666
+ // being read as toolbar-cursor commands (Home/End jumped the BAR to its
14667
+ // first/last button, ArrowLeft moved the bar cursor one step) instead of
14668
+ // moving the text cursor inside the input — the exact new keyboard
14669
+ // dead-end backlog #9 was supposed to fix, now reachable through #9 and
14670
+ // #10 together.
14671
+ //
14672
+ // Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
14673
+ // not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
14674
+ // focusin comment is explicit that SOME toolbar activations (list,
14675
+ // ordered-list, check, table, insert-before, insert-after, preview)
14676
+ // deliberately land a focusin on an editable block WITHOUT ending keynav,
14677
+ // so a real block surface must not trip this bail.
14678
+ //
14679
+ // ⚠ FIX ROUND 1 (self-review, caught before ship): `preview`'s own landing
14680
+ // is `sourceTextarea` — a real `<textarea>`, not a `.ed-wys-armed` block —
14681
+ // so the exemption above named it but the first cut of this function did
14682
+ // not actually implement it, and the omission is load-bearing: source
14683
+ // mode's model disables every button except outline/preview (this file's
14684
+ // own "S/4.1 Source mode" comment — the cursor is meant to be walked with
14685
+ // Left/Right/Home/End between those two, THEN activated), and MEASURED,
14686
+ // that walk is exactly what this function's first cut broke — the very
14687
+ // next arrow key after landing in the source textarea silently exited
14688
+ // keynav instead of moving between the two enabled buttons, closing off
14689
+ // the one route backlog #6's own still-open `preview` round-trip gap (see
14690
+ // activateToolbarCursor()'s comment) names as "THE WAY OUT". `sourceTextarea`
14691
+ // is therefore excluded by reference (not by tag/class — an ordinary
14692
+ // `textarea.ed-raw`, e.g. backlog #6's own quote/code rescue, still trips
14693
+ // this bail exactly as intended, since typing there IS the user's own
14694
+ // text, not bar navigation).
14695
+ function toolbarKeynavBlockedByRealControl() {
14696
+ const ae = document.activeElement;
14697
+ if (!ae || ae === sourceTextarea) return false;
14698
+ return !!(ae.tagName && (ae.tagName === 'INPUT' ||
14699
+ ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
14700
+ }
14701
+
13356
14702
  // Activation goes through the same call the click handler makes, so a
13357
14703
  // keyboard press and a mouse press cannot drift apart.
13358
14704
  //
13359
14705
  // WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
13360
14706
  // enabled row in a plain paragraph, pressing Enter on the cursor and then
13361
- // typing to see whether the characters land anywhere:
13362
- //
13363
- // * quote, code and line end on BODY and typing goes nowhere, with a
13364
- // clean burst and with a dirty one alike.
13365
- // * undo, redo and image join them ONLY when the surface has an
13366
- // uncommitted edit — the action resolves that burst first, and the
13367
- // re-render is what drops the caret. With nothing pending they land back
13368
- // on the armed paragraph and typing resumes.
13369
- // * everything else lands on an editable surface and typing resumes.
13370
- //
13371
- // A mouse click on those same buttons does exactly the same thing, so this
13372
- // is not something the keyboard route introduced — but a mouse user climbs
13373
- // out by clicking into the text and a keyboard user cannot, because Tab from
13374
- // BODY goes nowhere. Deferred to v3.4.0 rather than fixed here: the fix is
13375
- // in the activation path, which the mouse shares.
13376
- //
13377
- // THE WAY OUT until then, driven end to end from a stranded BODY: the entry
13378
- // chord still works there (the bar's own state survives, and its row is
13379
- // fully enabled), so walking to the mode button and pressing Enter puts the
13380
- // caret in the source textarea, where typing lands. Coming back the same way
13381
- // returns the document to edit mode but leaves the caret on BODY again — the
13382
- // hatch is somewhere to keep typing, not a way back to the WYSIWYG caret.
14707
+ // typing to see whether the characters land anywhere — UPDATED for Task 9
14708
+ // (backlog #6), which fixed the first two bullets below; only the third
14709
+ // remains an open gap, tracked below rather than left unstated:
14710
+ //
14711
+ // * FIXED: quote, code and line used to end on BODY with typing going
14712
+ // nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
14713
+ // `allowRawEditRescue` (convertBlockViaMenu() only — see its own
14714
+ // comment) and insertBlockBelow()'s `kind === 'line'` branch now open
14715
+ // that block's raw editor instead, so typing lands there.
14716
+ // * FIXED: undo, redo and image used to join them ONLY when the surface
14717
+ // had an uncommitted edit (switchAwayFrom()'s own commit-and-render is
14718
+ // what dropped the caret). undoViaToolbar()/redoViaToolbar()/
14719
+ // imageViaToolbar() now wrap the same captureCaretForSave()/
14720
+ // restoreCaretForSave() pair saveViaToolbar() uses.
14721
+ // * STILL OPEN: `preview` (cycleDocMode(), the document-wide edit↔source
14722
+ // toggle) is UNCHANGED — going INTO source mode focuses the source
14723
+ // textarea correctly, but the RETURN trip (source → edit) still leaves
14724
+ // the caret on BODY, MEASURED unchanged by this task's own review.
14725
+ // Deliberately left open rather than rushed: unlike the two bullets
14726
+ // above, there is no established rescue to reuse here —
14727
+ // captureCaretForSave()'s block-id + text-offset coordinate system has
14728
+ // no meaning for a source-textarea cursor position (a raw character
14729
+ // offset into the WHOLE document string, not one block's rendered
14730
+ // text), so a correct fix needs its own coordinate mapping and its own
14731
+ // tests, not a copy-paste of this task's other two fixes. Backlog #6's
14732
+ // first two clauses no longer make this escape hatch NECESSARY the way
14733
+ // they used to (quote/code/line now have their own direct rescue,
14734
+ // rather than needing the preview round-trip as their only way out),
14735
+ // which is why this is deferred rather than treated as still blocking.
14736
+ //
14737
+ // A mouse click on those same buttons does exactly the same thing, so none
14738
+ // of this is something the keyboard route introduced — a mouse user can
14739
+ // always climb out of the still-open `preview` case by clicking into any
14740
+ // OTHER editable text on the page, where a keyboard user cannot, because Tab
14741
+ // from BODY goes nowhere (this file's own §3.5 contract).
14742
+ //
14743
+ // THE WAY OUT for the still-open case, driven end to end from a stranded
14744
+ // BODY: the entry chord still works there (the bar's own state survives,
14745
+ // and its row is fully enabled), so walking to the mode button and pressing
14746
+ // Enter puts the caret in the source textarea, where typing lands. Coming
14747
+ // back the same way returns the document to edit mode but leaves the caret
14748
+ // on BODY again — the hatch is somewhere to keep typing, not (yet) a way
14749
+ // back to the WYSIWYG caret.
13383
14750
  function activateToolbarCursor() {
13384
14751
  const btn = toolbarRovingBtns()[toolbarRovingIndex];
13385
14752
  if (!btn || btn.disabled) return;
13386
- Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn))
14753
+ // Task 9 review M3: `true` — this IS the keyboard activation path (the
14754
+ // only caller of runToolbarAction() that is), threaded through so
14755
+ // toggleOutlineSidebar() can tell a keyboard Enter/Space apart from a
14756
+ // mouse click on the identical button. See its own comment for why
14757
+ // `toolbarRovingIndex >= 0` alone was the wrong test.
14758
+ Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn, true))
13387
14759
  .catch(() => {}).then(updateToolbar);
13388
14760
  }
13389
14761
 
@@ -13479,17 +14851,22 @@
13479
14851
  return bar;
13480
14852
  }
13481
14853
 
13482
- async function runToolbarAction(id, btnEl) {
14854
+ async function runToolbarAction(id, btnEl, viaKeyboard) {
13483
14855
  const blockEl = liveToolbarBlock();
13484
14856
  switch (id) {
14857
+ // v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
14858
+ // comment for why (commitThenSave()'s precondition and its own caret
14859
+ // rescue). backlog #6 gave undo()/redo() the same rescue below, via
14860
+ // undoViaToolbar()/redoViaToolbar() — see their shared comment.
14861
+ case 'save': return saveViaToolbar();
13485
14862
  // ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
13486
14863
  // `stack.redo`. Those are the pure core's helpers (they are what
13487
14864
  // rollbackFailedRender() reverses an op with) and calling them from
13488
14865
  // here would skip switchAwayFrom() and safeRerenderAll() — the exact
13489
14866
  // shape this file records as having produced a silent total lockout
13490
14867
  // recoverable only by reloading the page.
13491
- case 'undo': return undo();
13492
- case 'redo': return redo();
14868
+ case 'undo': return undoViaToolbar();
14869
+ case 'redo': return redoViaToolbar();
13493
14870
  case 'headings': return toggleToolbarHeadingMenu(btnEl);
13494
14871
  // The five conversion targets are convert-md.js's own ids, so the
13495
14872
  // gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
@@ -13513,24 +14890,76 @@
13513
14890
  case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
13514
14891
  case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
13515
14892
  case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
13516
- case 'image': return pickAndInsertImage();
13517
- case 'outline': return toggleOutlineSidebar();
14893
+ // backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
14894
+ // See its own comment (shared with undoViaToolbar()/redoViaToolbar())
14895
+ // for the caret rescue.
14896
+ case 'image': return imageViaToolbar();
14897
+ case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
13518
14898
  case 'preview': return cycleDocMode();
13519
14899
  default: return undefined;
13520
14900
  }
13521
14901
  }
13522
14902
 
13523
14903
  // ── headings ▾ ─────────────────────────────────────────────────────────
13524
- // KNOWN GAP, documented rather than fixed: the toolbar's keyboard cursor can
13525
- // open and close this dropdown, but its items are still reachable by mouse
13526
- // only — they carry no cursor of their own and the mode's arrow keys move
13527
- // along the bar, not into the panel. No keyboard route reaches them.
14904
+ // backlog #7 fix: the panel now carries its OWN roving cursor
14905
+ // (toolbarMenuIndex), a second index alongside the bar's toolbarRovingIndex
14906
+ // rather than a repurposing of it — the bar's cursor stays parked on
14907
+ // 'headings' the whole time the menu is open (K6's own pinned assertion:
14908
+ // Escape's first press takes the menu down and LEAVES `at: 'headings'`),
14909
+ // so the panel needed a cursor of its own rather than borrowing the bar's.
14910
+ // -1 means "nothing in the panel highlighted yet" — the state right after
14911
+ // opening, and the state K6 already pins Enter/Space's behaviour against
14912
+ // (see activateToolbarMenuCursor()'s own comment for why that case is not
14913
+ // reinterpreted as "select the first item").
14914
+ let toolbarMenuIndex = -1;
14915
+
14916
+ function toolbarMenuItems() {
14917
+ return toolbarMenuEl
14918
+ ? Array.prototype.slice.call(toolbarMenuEl.querySelectorAll('.ed-toolbar-menu-btn')) : [];
14919
+ }
14920
+
14921
+ function paintToolbarMenuCursor() {
14922
+ toolbarMenuItems().forEach((b, i) => {
14923
+ if (i === toolbarMenuIndex) b.setAttribute('data-ed-tb-menu-cursor', '');
14924
+ else b.removeAttribute('data-ed-tb-menu-cursor');
14925
+ });
14926
+ }
14927
+
14928
+ // None of the six items are ever disabled (every heading depth 1-6 is
14929
+ // always a legal target), so this is a plain wrapping step — no
14930
+ // moveToolbarCursor()-style "skip disabled" walk is needed.
14931
+ function moveToolbarMenuCursor(delta) {
14932
+ const items = toolbarMenuItems();
14933
+ if (!items.length) return;
14934
+ toolbarMenuIndex = ((toolbarMenuIndex + delta) % items.length + items.length) % items.length;
14935
+ paintToolbarMenuCursor();
14936
+ items[toolbarMenuIndex].scrollIntoView({ block: 'nearest' });
14937
+ }
14938
+
14939
+ // K6 (test/editor-journey.test.js) pins Enter/Space, pressed with NOTHING
14940
+ // in the panel highlighted, as the H▾ button's own open/close toggle — the
14941
+ // same activateToolbarCursor() the bar's Enter/Space already runs for
14942
+ // every other button. Only once the user has actually moved the panel
14943
+ // cursor (toolbarMenuIndex >= 0, via ArrowUp/ArrowDown below) does
14944
+ // Enter/Space switch to activating that ITEM instead — reusing the item's
14945
+ // own click handler via .click() so keyboard and mouse activation are the
14946
+ // same code path, the same discipline activateToolbarCursor() itself
14947
+ // documents for the bar's buttons.
14948
+ function activateToolbarMenuCursor() {
14949
+ const items = toolbarMenuItems();
14950
+ const item = toolbarMenuIndex >= 0 ? items[toolbarMenuIndex] : null;
14951
+ if (!item) { activateToolbarCursor(); return; }
14952
+ item.click();
14953
+ }
14954
+
13528
14955
  function closeToolbarMenu() {
13529
14956
  if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
14957
+ toolbarMenuIndex = -1;
13530
14958
  }
13531
14959
 
13532
14960
  function toggleToolbarHeadingMenu(btnEl) {
13533
14961
  if (toolbarMenuEl) { closeToolbarMenu(); return; }
14962
+ toolbarMenuIndex = -1;
13534
14963
  const menu = document.createElement('div');
13535
14964
  menu.className = 'ed-toolbar-menu';
13536
14965
  [1, 2, 3, 4, 5, 6].forEach((n) => {
@@ -13683,15 +15112,28 @@
13683
15112
  // lib/md2doc.js. Named for the non-default state because the desktop
13684
15113
  // outline is a sticky column shown by default, which is also what lets
13685
15114
  // aria-expanded below stay truthful on both viewports.
13686
- function toggleOutlineSidebar() {
15115
+ function toggleOutlineSidebar(viaKeyboard) {
13687
15116
  // The same 1080px breakpoint the stylesheet's own drawer block uses.
13688
15117
  const mobile = typeof window.matchMedia === 'function' &&
13689
15118
  window.matchMedia('(max-width: 1080px)').matches;
13690
15119
  let expanded;
13691
15120
  if (mobile) {
15121
+ // Mirror onto documentElement too, exactly as lib/md2doc.js's own
15122
+ // setSidebarOpen() does — the scroll lock is `html[data-sidebar-open]
15123
+ // { overflow: hidden }` (documentElement, not body, is what a real
15124
+ // gesture actually scrolls; see that rule's comment in lib/md2doc.js).
15125
+ // Final-review C1: this toolbar entry point set only `body`, so the
15126
+ // drawer visually opened but the scroll lock never engaged — the ONLY
15127
+ // way into the drawer in edit mode (`.sidebar-toggle` is display:none
15128
+ // there, see T11-2) left backlog #12 unfixed for edit mode specifically.
13692
15129
  expanded = !document.body.hasAttribute('data-sidebar-open');
13693
- if (expanded) document.body.setAttribute('data-sidebar-open', '');
13694
- else document.body.removeAttribute('data-sidebar-open');
15130
+ if (expanded) {
15131
+ document.body.setAttribute('data-sidebar-open', '');
15132
+ document.documentElement.setAttribute('data-sidebar-open', '');
15133
+ } else {
15134
+ document.body.removeAttribute('data-sidebar-open');
15135
+ document.documentElement.removeAttribute('data-sidebar-open');
15136
+ }
13695
15137
  } else {
13696
15138
  expanded = document.body.hasAttribute('data-ed-outline-hidden');
13697
15139
  if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
@@ -13699,6 +15141,42 @@
13699
15141
  }
13700
15142
  const toggle = document.getElementById('sidebar-toggle');
13701
15143
  if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
15144
+ // backlog #9: v3.3.0 gave a keyboard route to OPEN the drawer (this very
15145
+ // button, reachable through the toolbar's F12 keynav) but none to
15146
+ // OPERATE what is inside it — the TOC links and the search box
15147
+ // (`#doc-search-input`, lib/md2doc.js's reader-sidebar markup) were
15148
+ // unreachable by keyboard once the drawer was open, because F12's
15149
+ // cursor is VIRTUAL (see its own load-bearing comment: "no toolbar
15150
+ // button ever takes DOM focus") and a bare Tab with NOTHING focused is
15151
+ // deliberately swallowed elsewhere in this file (§3.5's block-indent
15152
+ // contract, a few hundred lines down) — so a keyboard user who had
15153
+ // never clicked into a real control was left with no Tab destination at
15154
+ // all, drawer open or not.
15155
+ //
15156
+ // Scoped to the KEYBOARD path only: a mouse click reaches this same
15157
+ // function, and buildToolbar()'s own mousedown-preventDefault() note is
15158
+ // explicit that a toolbar press must NOT steal focus from whatever
15159
+ // burst the mouse user has open — forcing focus into the search box on
15160
+ // every click would blur that burst (committing it) for no reason a
15161
+ // mouse user asked for. Also scoped to `expanded`: a toggle that just
15162
+ // HID the drawer has nowhere sensible to send focus.
15163
+ //
15164
+ // Task 9 review M3: `viaKeyboard`, an explicit parameter threaded from
15165
+ // activateToolbarCursor() — NOT `toolbarRovingIndex >= 0`, which was
15166
+ // the first cut's test and is wrong: toolbarRovingIndex says whether
15167
+ // keynav is CURRENTLY on, not which INPUT DEVICE fired this particular
15168
+ // activation. MEASURED: press Alt+F10 (keynav now on), then click ☰
15169
+ // with the MOUSE — toolbarRovingIndex is still >= 0 from the earlier
15170
+ // keypress, so the old test treated a plain mouse click as "keyboard",
15171
+ // stealing focus into the search box exactly as the commit message for
15172
+ // backlog #9 says it must not ("a mouse click on the same button must
15173
+ // not steal focus"). `viaKeyboard` is true on ONE call path only —
15174
+ // activateToolbarCursor(), which IS the keyboard route — regardless of
15175
+ // whatever toolbarRovingIndex happens to hold at the time.
15176
+ if (expanded && viaKeyboard) {
15177
+ const search = document.getElementById('doc-search-input');
15178
+ if (search) search.focus();
15179
+ }
13702
15180
  }
13703
15181
 
13704
15182
  // ── 追加 3: images ─────────────────────────────────────────────────────
@@ -13897,6 +15375,21 @@
13897
15375
  input.click();
13898
15376
  }
13899
15377
 
15378
+ // backlog #6 — see undoViaToolbar()/redoViaToolbar()'s shared comment.
15379
+ // pickAndInsertImage() resolves (its own switchAwayFrom() included) well
15380
+ // before the user has picked a file — the `input.click()` above only OPENS
15381
+ // the native file dialog, it does not await a selection — so capturing
15382
+ // before and restoring right after this call lands at exactly the same
15383
+ // point in the gesture saveViaToolbar()'s pair does for `save`: after the
15384
+ // commit-and-render that can strand the caret, before the (much later,
15385
+ // asynchronous, maybe-never-arrives) file selection that focuses somewhere
15386
+ // new anyway.
15387
+ async function imageViaToolbar() {
15388
+ const saved = captureCaretForSave();
15389
+ await pickAndInsertImage();
15390
+ restoreCaretForSave(saved);
15391
+ }
15392
+
13900
15393
  function dragCarriesFiles(e) {
13901
15394
  const dt = e.dataTransfer;
13902
15395
  if (!dt || !dt.types) return false;
@@ -13924,12 +15417,20 @@
13924
15417
  // user is looking at is not the content a drop would write into" — a
13925
15418
  // future third mode inherits the refusal rather than the hole.
13926
15419
  document.addEventListener('dragover', (e) => {
15420
+ // Nothing is dropped into a document behind a modal. Returning WITHOUT
15421
+ // preventDefault() is what makes the browser refuse the drop, so the
15422
+ // gesture is declined visibly rather than swallowed.
15423
+ if (waveEditorIsModal()) return;
13927
15424
  if (docMode !== 'edit') return;
13928
15425
  if (!dragCarriesFiles(e)) return;
13929
15426
  e.preventDefault();
13930
15427
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
13931
15428
  });
13932
15429
  document.addEventListener('drop', (e) => {
15430
+ // MEASURED before this line existed: a PNG dropped on the open wave panel
15431
+ // took the document behind it from 4 blocks to 5 and put an `<img>` into
15432
+ // `.content`, with the overlay still up and saying nothing.
15433
+ if (waveEditorIsModal()) return;
13933
15434
  if (docMode !== 'edit') return;
13934
15435
  if (!dragCarriesFiles(e)) return;
13935
15436
  const files = e.dataTransfer
@@ -14035,6 +15536,7 @@
14035
15536
  // Capture phase: this only records intent, and must do so before any
14036
15537
  // other keydown handler can preventDefault() its way past it.
14037
15538
  document.addEventListener('keydown', (e) => {
15539
+ if (waveEditorIsModal()) return;
14038
15540
  plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
14039
15541
  (e.key === 'v' || e.key === 'V'));
14040
15542
  }, true);
@@ -14144,6 +15646,15 @@
14144
15646
  updateToolbar();
14145
15647
  });
14146
15648
  document.addEventListener('click', (e) => {
15649
+ // v3.4.0 batch3 Task 6: this file has TWO document-level `click`
15650
+ // delegates, and for one round only the other one asked. Nothing behind
15651
+ // the modal takes clicks — measured benign here today (the toolbar menu
15652
+ // cannot be open behind a modal, keynav cannot be entered while the main
15653
+ // keydown returns first, and `closest('.ed-block')` is null for anything
15654
+ // in the overlay), but "benign today" is a property of the three branches
15655
+ // below rather than of the boundary, and the next branch added here would
15656
+ // inherit an unguarded seat behind a modal.
15657
+ if (waveEditorIsModal()) return;
14147
15658
  if (!e.target || !e.target.closest) return;
14148
15659
  if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
14149
15660
  closeToolbarMenu();
@@ -14262,24 +15773,134 @@
14262
15773
  // keydown ends the mode and the arrow behind it falls through to
14263
15774
  // the block-selection extension instead.
14264
15775
  //
14265
- // A menu raised from this bar goes down on the way past. Without this the
14266
- // hand-back cleared the cursor cue and left the H▾ dropdown standing, so
14267
- // the two disagreed and only Escape was any use. Escape is excluded
14268
- // because the ordered prologue below owns it and takes the menu down
14269
- // there, in rank order; Enter and Space are excluded because they are the
14270
- // keys that opened the menu and have to keep closing it.
14271
- if (toolbarRovingIndex >= 0) {
14272
- if (toolbarMenuEl && e.key !== 'Escape' && e.key !== 'Enter' && e.key !== ' ') {
14273
- closeToolbarMenu();
15776
+ // A menu raised from this bar goes down on the way past — UNLESS the key
15777
+ // is one the panel itself now claims (backlog #7, see the nested
15778
+ // `if (toolbarMenuEl)` just below). Before that fix ALL of
15779
+ // Up/Down/Enter/Space fell through this whole file with the menu open
15780
+ // and its six items unreachable by keyboard; without the panel going
15781
+ // down too on everything else, the hand-back cleared the cursor cue and
15782
+ // left the H▾ dropdown standing, so the two disagreed and only Escape
15783
+ // was any use. Escape is excluded because the ordered prologue below
15784
+ // owns it and takes the menu down there, in rank order.
15785
+ // Task 9 review I2: a real control (search box, raw editor textarea)
15786
+ // holding focus quietly ends keynav and hands EVERY key straight to it
15787
+ // — see toolbarKeynavBlockedByRealControl()'s own comment. No banner:
15788
+ // this is not a stray key with nowhere to go, it is the user typing
15789
+ // into something they can see is focused.
15790
+ // ── v3.4.0 batch3 Task 6: the wave editor owns the keyboard, EXCEPT ──
15791
+ // Above everything, including the toolbar's keyboard mode. While that
15792
+ // overlay is up it is modal over the document, and almost every branch
15793
+ // below this line is about a document the user cannot see or reach —
15794
+ // including the `Tab` branch, which used to swallow the key and leave all
15795
+ // of an `aria-modal` dialog's controls unreachable. The overlay's own
15796
+ // capture-phase handler owns Escape, Tab and undo/redo and stops them
15797
+ // before they get here; this is the other half of that deal, stated on this
15798
+ // side so the two cannot drift into disagreeing about who owns what.
15799
+ //
15800
+ // SAVE IS THE EXCEPTION, and it is the exception because ownership is about
15801
+ // which gestures, not only about who. Ctrl+S is not a gesture aimed at the
15802
+ // document the modal is covering — it is the reflex that protects work, and
15803
+ // the wave editor has nothing of its own to save in this task. Swallowing
15804
+ // it cost twice over, both measured: the keystroke did nothing and said
15805
+ // nothing (`disk has MARKA? false` with the overlay open, `true` after
15806
+ // Escape), and — worse — `showConflictBanner()` has exactly ONE producer,
15807
+ // the 409 branch of `save()`, while the toolbar's save control sits under an
15808
+ // `inset: 0` backdrop and outside the focus trap. So with Ctrl+S swallowed,
15809
+ // a disk conflict arising DURING a wave session could not raise a banner at
15810
+ // all: measured, file changed underneath, overlay open, Ctrl+S, banner
15811
+ // `null`. All the work keeping `.ed-conflict` above the modal and inside the
15812
+ // focus trap only ever fired for a banner that predated the overlay.
15813
+ //
15814
+ // ⚠ fix 4 (R1): something else now DEPENDS on this line being as narrow as
15815
+ // it is. `waveDiscardIsSafe()` decides whether a wave session's commits can
15816
+ // be popped off the document stack by comparing a COUNT
15817
+ // (`depth === baseDepth + ops`), which proves only that the same number of
15818
+ // entries sit above where the session started — a foreign push paired with
15819
+ // a foreign pop inside one session satisfies it, and
15820
+ // `discardPristineInsert()` is exactly that shape. It is unreachable today
15821
+ // because this gate returns on every non-save key and because
15822
+ // `openWaveEditor()` resolves the one remaining path (Ctrl+S's
15823
+ // `switchAwayFrom()`) BEFORE the overlay opens. Letting one more gesture
15824
+ // through here re-opens that hole silently; if that is ever the right call,
15825
+ // waveDiscardIsSafe()'s check has to become identity-based first.
15826
+ if (waveEditorIsModal() && !isSaveGesture(e)) return;
15827
+ if (toolbarRovingIndex >= 0 && toolbarKeynavBlockedByRealControl()) {
15828
+ exitToolbarKeynav();
15829
+ } else if (toolbarRovingIndex >= 0) {
15830
+ // backlog #7: the H▾ panel claims Up/Down/Enter/Space for its OWN
15831
+ // roving cursor before anything below gets a look at them — see
15832
+ // toolbarMenuIndex's own comment for why that is a second index
15833
+ // rather than a repurposing of toolbarRovingIndex. Every other key
15834
+ // (Left/Right/Home/End/Tab/…) still closes the panel exactly as
15835
+ // before, which is why the fallback line at the end here is the
15836
+ // ORIGINAL condition with the now-claimed keys still excluded from it
15837
+ // (Escape stays excluded too — the ordered prologue below owns it, in
15838
+ // rank order, same as always).
15839
+ if (toolbarMenuEl) {
15840
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveToolbarMenuCursor(1); return; }
15841
+ if (e.key === 'ArrowUp') { e.preventDefault(); moveToolbarMenuCursor(-1); return; }
15842
+ if (e.key === 'Enter' || e.key === ' ') {
15843
+ e.preventDefault(); activateToolbarMenuCursor(); return;
15844
+ }
15845
+ if (e.key !== 'Escape') closeToolbarMenu();
14274
15846
  }
14275
15847
  if (e.key === 'ArrowRight') { e.preventDefault(); moveToolbarCursor(1); return; }
14276
15848
  if (e.key === 'ArrowLeft') { e.preventDefault(); moveToolbarCursor(-1); return; }
15849
+ // backlog #10: Home/End are the ARIA toolbar pattern's own "jump to
15850
+ // the first/last item" keys — a natural extension of the roving pair
15851
+ // just above, not a new idiom. Naming them here (instead of letting
15852
+ // them fall to the hand-back branch below) turns two of the seven
15853
+ // keys MEASURED to silently drop the cursor into something USEFUL.
15854
+ if (e.key === 'Home') { e.preventDefault(); moveToolbarCursorToEdge(-1); return; }
15855
+ if (e.key === 'End') { e.preventDefault(); moveToolbarCursorToEdge(1); return; }
14277
15856
  if (e.key === 'Enter' || e.key === ' ') {
14278
15857
  e.preventDefault(); activateToolbarCursor(); return;
14279
15858
  }
15859
+ // backlog #10: everything else hands the key back ON PURPOSE — Tab
15860
+ // moves real DOM focus per the document's own tab order (see
15861
+ // buildToolbar()'s own "Tab key belongs to the document's own
15862
+ // structure" note), and a plain character or Backspace has nowhere to
15863
+ // land while the virtual cursor sits over a non-editable BODY, so
15864
+ // letting them fall through is harmless. What is NOT harmless,
15865
+ // MEASURED (v3.3.0): the hand-back left NOTHING on screen to mark it —
15866
+ // `[data-ed-tb-cursor]` and `data-ed-tb-keynav` both went null with the
15867
+ // bar looking exactly as it did the instant before Alt+F10 was first
15868
+ // pressed, so a user who does not catch the outline vanishing has no
15869
+ // way to tell they fell out of keyboard mode. Two fixes were on the
15870
+ // table (v3.3.0's own ruling: 「一併處理,加上可見的信號」 — do both):
15871
+ // teach the specific keys that have somewhere useful to go (Home/End,
15872
+ // above) to stop exiting, AND raise a visible banner on every hand-back
15873
+ // that still exits. Enumerating "every key with nowhere useful to go"
15874
+ // was rejected as the SOLE fix — that list is open-ended and this file
15875
+ // cannot audit it for completeness, while a banner on every hand-back
15876
+ // is correct for any key this branch is ever reached by, including
15877
+ // ones nobody has measured yet.
15878
+ //
15879
+ // Task 9 review I3: `Tab` is excluded from the banner here (though it
15880
+ // still exits keynav, same as before) — it is the ARIA toolbar
15881
+ // pattern's OWN "leave the toolbar" gesture, a legitimate exit rather
15882
+ // than a stray key with nowhere to go, and raising a full-width red
15883
+ // banner over the whole bar for a correct keystroke was the wrong
15884
+ // direction. MEASURED AT THE TIME: dismissKeynavExitBanner() was the
15885
+ // only way a keyboard user could take the earlier, unconditional
15886
+ // banner back down at all (`.ed-conflict` was `z-index:999` over the
15887
+ // bar's own `z-index:101` AND `top:0` against the bar's own `top:0`,
15888
+ // covering all 23 buttons; a bare Tab with nothing focused is
15889
+ // swallowed by this file's own §3.5 contract, so Escape was the only
15890
+ // route left) — excluding Tab here removes most of the cases that
15891
+ // needed that route in the first place, and Escape's own rung below
15892
+ // closes the rest. Task 10 (backlog #8) has since moved `.ed-conflict`
15893
+ // to `top: var(--ed-toolbar-h)`, so it no longer covers the bar for
15894
+ // ANY banner — the "covering all 23 buttons" clause above is
15895
+ // historical (what justified wiring Escape at the time), not the
15896
+ // current geometry.
14280
15897
  if (e.key !== 'Escape' && e.key !== 'Alt' && e.key !== 'Shift' &&
14281
15898
  e.key !== 'Control' && e.key !== 'Meta') {
14282
15899
  exitToolbarKeynav();
15900
+ if (e.key !== 'Tab') {
15901
+ showBanner(TOOLBAR_KEYNAV_EXIT_MESSAGE, null, null);
15902
+ activeBannerIsKeynavExit = true;
15903
+ }
14283
15904
  }
14284
15905
  } else if (e.altKey && e.key === 'F10') {
14285
15906
  e.preventDefault();
@@ -14353,6 +15974,13 @@
14353
15974
  // bar, so it goes out before the bar's keyboard mode does, matching the
14354
15975
  // way the menu rungs above rank against the selection rung below.
14355
15976
  if (toolbarMenuEl) { e.preventDefault(); closeToolbarMenu(); return; }
15977
+ // Task 9 review I3: the keynav-exit banner (backlog #10) is only ever
15978
+ // up once keynav has ALREADY exited, so this and the
15979
+ // exitToolbarKeynav() rung right below it never both apply to the
15980
+ // same Escape — order between the two is not load-bearing, grouped
15981
+ // here only for readability. See dismissKeynavExitBanner()'s own
15982
+ // comment for why Escape, specifically, is safe to wire to it.
15983
+ if (dismissKeynavExitBanner()) { e.preventDefault(); return; }
14356
15984
  if (exitToolbarKeynav()) { e.preventDefault(); return; }
14357
15985
  if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
14358
15986
  // Nothing above owns this Escape — fall through to the burst
@@ -14372,6 +16000,17 @@
14372
16000
  // commit failure switchAwayFrom() returns false — the banner it
14373
16001
  // already showed is the visible reason save() is skipped; the burst
14374
16002
  // stays open with the user's text intact rather than saving nothing.
16003
+ //
16004
+ // v3.4.0 §3: this exact precondition — resolve first, save second —
16005
+ // is now also named once as commitThenSave(), for the toolbar `save`
16006
+ // button (see its own comment). Left spelled out here rather than
16007
+ // switched to call it: this line is pinned verbatim by a static
16008
+ // source-shape check in test/editor-client.test.js's
16009
+ // shortcutOrderChecks, which is outside this task's file scope, and
16010
+ // the two spellings are behaviourally identical fire-and-forget calls
16011
+ // — nothing is duplicated here beyond this one line shape, and
16012
+ // save()'s own body is still called exactly once, from inside
16013
+ // commitThenSave() when the toolbar path runs it.
14375
16014
  switchAwayFrom().then((ok) => { if (ok) save(); });
14376
16015
  return;
14377
16016
  }
@@ -14447,7 +16086,19 @@
14447
16086
  // `blockSelection` is the gate, and it is the whole guard: with no set
14448
16087
  // standing there is no focus holder either, and every key below behaves
14449
16088
  // exactly as it did pre-S3.
14450
- if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey) {
16089
+ //
16090
+ // v3.4.0 batch3 Task 6: that invariant is now CHECKED and not assumed. It
16091
+ // was true only because nothing had ever mounted a text field outside
16092
+ // `.content`; the wave editor puts real `<input>`s on document.body, which
16093
+ // are neither `.ed-wys-armed` nor `.ed-wys-cell` either, and a Backspace
16094
+ // typed into one ran deleteSelection() — MEASURED: 3 blocks became 2, the
16095
+ // tail paragraph was gone from the document, and the keystroke never
16096
+ // reached the input at all. Silent data loss behind a modal. A guard that
16097
+ // holds only because nobody has done the thing it forbids is not a guard,
16098
+ // so the text-surface test is spelled out rather than inferred from two
16099
+ // class names, and it protects every future surface too.
16100
+ if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey &&
16101
+ !isTextEntryTarget(e.target)) {
14451
16102
  if (e.key === 'Tab') {
14452
16103
  // §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
14453
16104
  // 瀏覽器焦點巡覽.
@@ -14468,6 +16119,52 @@
14468
16119
  }
14469
16120
  }
14470
16121
 
16122
+ // v3.4.0 batch3 Task 8: the wave editor's KEYBOARD entry.
16123
+ //
16124
+ // Until this, the only way into it was the `編輯波形` affordance, which is
16125
+ // raised by a `mouseover` and positioned over the diagram — so a keyboard
16126
+ // user could not open it at all. That is the same defect this file has
16127
+ // already paid for twice on other surfaces: an affordance only a pointer
16128
+ // can raise (v3.3.0's `H▾` dropdown) and a panel with no keyboard route
16129
+ // into its contents (the outline drawer, backlog #9).
16130
+ //
16131
+ // Gated on the BLOCK WRAPPER ITSELF, not on `blockSelection` and not on
16132
+ // `closest()`: the focus holder is the same `.ed-block` element in both of
16133
+ // the states that can get here — a standing selection (whose roving
16134
+ // `tabindex` focuses it) and the focus this file hands back when a wave
16135
+ // session closes — so one branch serves the way in and the way back in.
16136
+ //
16137
+ // `closest('.ed-block')` is what the first cut used and it was wrong by
16138
+ // exactly this defect family: EVERY block contains an `.ed-insert` and an
16139
+ // `.ed-handle` <button>, so Enter on the ⠿ of a wavedrom block opened the
16140
+ // wave editor and `preventDefault()`ed the block menu, while SPACE on the
16141
+ // same button still opened the menu — one button, two devices'
16142
+ // conventions, two answers, which is v3.3.0 backlog #4 with the devices
16143
+ // swapped. That path also has no `blockSelection`, so closing the session
16144
+ // it wrongly opened would have ended on `document.body`. It was latent
16145
+ // only because nothing can put the keyboard on those buttons today, and it
16146
+ // would have gone live with the very follow-up this task is asking for.
16147
+ //
16148
+ // `isTextEntryTarget` keeps it away from every editable, where Enter
16149
+ // already means a paragraph break, and the modal gate far above means it
16150
+ // cannot fire while the overlay is up.
16151
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
16152
+ !isTextEntryTarget(e.target) && e.target && e.target.classList &&
16153
+ e.target.classList.contains('ed-block')) {
16154
+ const blockEl = e.target;
16155
+ const diagram = waveDiagramInside(blockEl);
16156
+ if (diagram !== null) {
16157
+ e.preventDefault();
16158
+ // Same shape as the affordance's own click handler: reported, not
16159
+ // rethrown. An unhandled rejection out of a keydown listener is a
16160
+ // page-level error, and openWaveEditor() unwinds its own state.
16161
+ openWaveEditor(blockEl).catch((err) => {
16162
+ console.error('md2doc: the wave editor could not open', err);
16163
+ });
16164
+ return;
16165
+ }
16166
+ }
16167
+
14471
16168
  // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
14472
16169
  // branch above is keyed on the event target being some edit surface, and
14473
16170
  // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
@@ -14761,6 +16458,12 @@
14761
16458
  });
14762
16459
 
14763
16460
  document.addEventListener('paste', (e) => {
16461
+ // Same question as `drop` above, and for the same reason: what the document
16462
+ // behind a modal receives is nothing. Today the two branches below already
16463
+ // require a `.ed-wys-cell` / `.ed-wys-armed` target, which a paste into a
16464
+ // wave field is not — this is here so the boundary is one predicate rather
16465
+ // than a property of which targets those two selectors happen to match.
16466
+ if (waveEditorIsModal()) return;
14764
16467
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
14765
16468
  if (cellEl) {
14766
16469
  e.preventDefault();
@@ -14930,25 +16633,1394 @@
14930
16633
  document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
14931
16634
 
14932
16635
  window.addEventListener('beforeunload', (e) => {
14933
- if (stack.dirtyDepth !== 0 || burstHasUncommittedEdit()) {
16636
+ // Review I1: documentIsDirty(), not a second copy of the expression it
16637
+ // wraps — this was the second caller still spelling it out by hand.
16638
+ if (documentIsDirty()) {
14934
16639
  e.preventDefault();
14935
16640
  e.returnValue = '';
14936
16641
  return '';
14937
16642
  }
14938
16643
  });
14939
16644
 
16645
+ // v3.4.0 batch2 Task 6 (Ruling B2-2) — background refresh for a referenced
16646
+ // `.drawio`/`.xml` file that changed on disk, triggered by the ping loop
16647
+ // below seeing `{stale:true}`.
16648
+ //
16649
+ // Deliberately NOT rerenderAll(): that function carries edit-commit
16650
+ // semantics nobody asked for here (dropDiscardedBurst/dropDiscardedRawEdit,
16651
+ // the swallowed-fence banner, `pendingSelectionRange` consumption) — this
16652
+ // only ever touches blocks whose OWN baked diagram actually differs, never
16653
+ // reassigns `blocks`/`lines` (the source text did not change, only a
16654
+ // referenced file's bytes did), and never moves focus out of an editor.
16655
+ //
16656
+ // ── Fix round 2 (re-review G1): ONE criterion for "is there a diagram here"
16657
+ //
16658
+ // The first cut asked two different questions. The pre-fetch guard asked the
16659
+ // DOM (`querySelector('.drawio')`); the per-block loop asked a text regex
16660
+ // over the part strings. They disagree on a block whose rendered markup
16661
+ // merely CONTAINS the literal — a fenced code block or a table cell
16662
+ // documenting this very feature, which this repo's own docs plausibly hold.
16663
+ // Three consequences, all of them real:
16664
+ // * a fenced code block selected as a diagram block and was replaced
16665
+ // wholesale on every re-bake (G3),
16666
+ // * the pre-fetch guard could not see a bail the loop would take, so with
16667
+ // F2's retry in place the caret resting in that code block cost a full
16668
+ // /api/render — headless Chromium, every page of every referenced
16669
+ // diagram — every 10 seconds, forever, with the diagram never updating,
16670
+ // * and because the bootstrap seed is the HTML parser's re-serialisation
16671
+ // rather than the server's own strings, the cheap `parts[i] ===
16672
+ // lastParts[i]` equality never fired on an unedited tab, so ONE external
16673
+ // write re-swapped EVERY diagram block on the page (G4).
16674
+ //
16675
+ // Both sides now ask the same question, and it is the structural one: does
16676
+ // this markup contain a real element with class `drawio`? The old side reads
16677
+ // the live node; the new side parses the part and reads that. Text that
16678
+ // merely quotes the markup parses to a text node inside <code> and is not
16679
+ // selected by anything. DRAWIO_TEXT_HINT survives only as a cheap SUPERSET
16680
+ // prefilter so the parse is skipped for the overwhelming majority of blocks
16681
+ // — it never decides anything on its own.
16682
+ //
16683
+ // And "did it actually change" is answered by a fingerprint of the baked
16684
+ // diagram taken through the SAME parser on both sides (so the seed's
16685
+ // non-byte-identity cannot leak in), deliberately ignoring `hidden` — that
16686
+ // is the reader's page selection, not part of the bake.
16687
+ const DRAWIO_TEXT_HINT = /class="[^"]*\bdrawio\b/;
16688
+ const DRAWIO_FP_SEP = '';
16689
+
16690
+ // Fix round 3 (re-review2 H2): ALL of them, not the first.
16691
+ // `![a](a.drawio) ![b](b.drawio)` on one markdown line is ONE block holding
16692
+ // TWO `.drawio` boxes — verified by render, and a table row of diagrams does
16693
+ // the same. Round 2 compared only `querySelector('.drawio')`, so a change to
16694
+ // the SECOND file left the comparison equal, the loop `continue`d, `applied`
16695
+ // stayed true, the tab ACKED, and the server advanced its baseline past an
16696
+ // update the DOM never took: silent, permanent, and exactly the invariant
16697
+ // F2 exists to hold. Everything below therefore works on the whole list.
16698
+ function drawioBoxesIn(node) {
16699
+ return (node && node.querySelectorAll)
16700
+ ? Array.prototype.slice.call(node.querySelectorAll('.drawio'))
16701
+ : [];
16702
+ }
16703
+ function drawioBoxIn(node) {
16704
+ return (node && node.querySelector) ? node.querySelector('.drawio') : null;
16705
+ }
16706
+ function parsePartMarkup(part) {
16707
+ const t = document.createElement('template');
16708
+ t.innerHTML = part;
16709
+ return t;
16710
+ }
16711
+ // Fix round 3 (re-review2 H3): the reader's annotation feature mutates the
16712
+ // live `.drawio-page` IN PLACE — `annoSyncInline()` (lib/md2doc.js) wraps the
16713
+ // visible <svg> in `<span class="anno-inline-wrap">` and appends an
16714
+ // `<svg class="anno-inline">` overlay beside it, and the wrap is never
16715
+ // removed again. The fresh parse of a part never carries either, so an
16716
+ // annotated diagram could NEVER fingerprint equal, and G4's whole point —
16717
+ // "a write to a.drawio must not touch the block holding b.drawio" — failed
16718
+ // for exactly the blocks carrying the reader's own work.
16719
+ //
16720
+ // Normalising undoes the wrap on a CLONE so the comparison sees the bake
16721
+ // again. MEASURED: normalising an annotated page yields a string byte-equal
16722
+ // to an unannotated page baked from the same file (1170 chars vs 2171 raw).
16723
+ // The clone only happens when the cheap `querySelector` says there is
16724
+ // something to undo, so an unannotated document pays one selector per page.
16725
+ function drawioPageMarkup(pageEl) {
16726
+ if (!pageEl.querySelector('.anno-inline-wrap')) return pageEl.innerHTML;
16727
+ const c = pageEl.cloneNode(true);
16728
+ Array.prototype.forEach.call(c.querySelectorAll('svg.anno-inline'),
16729
+ function (o) { if (o.parentNode) o.parentNode.removeChild(o); });
16730
+ Array.prototype.forEach.call(c.querySelectorAll('.anno-inline-wrap'),
16731
+ function (w) {
16732
+ const parent = w.parentNode;
16733
+ if (!parent) return;
16734
+ while (w.firstChild) parent.insertBefore(w.firstChild, w);
16735
+ parent.removeChild(w);
16736
+ });
16737
+ return c.innerHTML;
16738
+ }
16739
+ // Identity of what was BAKED into one `.drawio` box. `hidden` is excluded on
16740
+ // purpose (page selection is view state this function must not react to),
16741
+ // and so is the page-navigation node the init hook appends — it is a child
16742
+ // of the box, never of a `.drawio-page`. A failed bake has no pages, so its
16743
+ // rendered error text is its identity instead.
16744
+ function drawioFingerprint(box) {
16745
+ if (!box) return null;
16746
+ const pages = box.querySelectorAll('.drawio-page');
16747
+ if (!pages.length) return 'raw' + DRAWIO_FP_SEP + box.innerHTML;
16748
+ const out = ['pages' + DRAWIO_FP_SEP + (box.getAttribute('data-drawio-pages') || '')];
16749
+ for (let i = 0; i < pages.length; i++) out.push(drawioPageMarkup(pages[i]));
16750
+ return out.join(DRAWIO_FP_SEP);
16751
+ }
16752
+ // Identity of every diagram in one block. The count leads, so a block that
16753
+ // gained or lost a box can never collide with one that did not.
16754
+ function drawioBlockFingerprint(boxes) {
16755
+ const out = [String(boxes.length)];
16756
+ for (let i = 0; i < boxes.length; i++) out.push(drawioFingerprint(boxes[i]));
16757
+ return out.join(DRAWIO_FP_SEP + DRAWIO_FP_SEP);
16758
+ }
16759
+
16760
+ let drawioRefreshInFlight = false;
16761
+ // Fix round 1 (review F2): the token from the last refresh that ACTUALLY
16762
+ // landed in the DOM, to be handed back on the next heartbeat. The server
16763
+ // advances that tab's staleness baseline on this and on nothing else, so
16764
+ // every bail path below — by simply not setting it — is genuinely retried
16765
+ // on the next tick instead of losing the change for the rest of the
16766
+ // session.
16767
+ let drawioAckPending = null;
16768
+
16769
+ // ── Fix round 2 (re-review G1b): a persistent bail must not cost a bake ──
16770
+ //
16771
+ // The three pre-fetch bails below make the long-lived conditions free, and
16772
+ // G1a removes the one post-fetch bail whose condition the pre-fetch side
16773
+ // could not see. What this backs up is everything that cannot be enumerated
16774
+ // in advance: /api/render answering 500 deterministically, a server whose
16775
+ // ref list flaps between two tabs, a future bail nobody thought about here.
16776
+ // Any of those would otherwise re-issue a full render + Chromium bake on
16777
+ // every 10s beat for the life of the session.
16778
+ //
16779
+ // Only a bail that already PAID for a fetch counts. A pre-fetch bail is free
16780
+ // and must stay instant, or a reader who simply rested their caret in a
16781
+ // diagram block would be punished with minutes of latency once they moved.
16782
+ //
16783
+ // The update is delayed, never dropped: the server keeps re-reporting the
16784
+ // same staleness (F2's invariant is untouched), and the next attempt after
16785
+ // the blocking condition clears applies it. DOWNSIDE, stated plainly: after
16786
+ // a run of paid bails, a refresh that WOULD now succeed can wait up to
16787
+ // DRAWIO_BACKOFF_CAP_MS before it is tried. That is the price of bounding
16788
+ // the bake rate to once per that interval instead of once per beat.
16789
+ const DRAWIO_BACKOFF_BASE_MS = 10000;
16790
+ const DRAWIO_BACKOFF_CAP_MS = 160000;
16791
+ const DRAWIO_FETCH_TIMEOUT_MS = 120000;
16792
+ let drawioBailStreak = 0;
16793
+ let drawioNextAttemptAt = 0;
16794
+
16795
+ // Fix round 2 (re-review G6): the deferred notice now carries the moment it
16796
+ // was raised. A banner that surfaces minutes late describes a landing the
16797
+ // reader has long since navigated away from — worse than no banner, and the
16798
+ // same "teaches the user to ignore it" failure F6 was about.
16799
+ const DRAWIO_NOTICE_TTL_MS = 60000;
16800
+ // Fix round 1 (review F5): a notice this background path wants to show but
16801
+ // must not show YET. showBanner() is destructive by construction — its
16802
+ // first statement removes whatever banner is up — and every other caller in
16803
+ // this file is a response to a user gesture. This one fires from a timer,
16804
+ // and the banner it would remove can be the disk-conflict one, which is the
16805
+ // user's only route to resolving a conflict on the one data-safety-critical
16806
+ // path this editor has (it carries the Reload button). So the notice waits
16807
+ // for an empty slot instead of taking one.
16808
+ let drawioDeferredNotice = null;
16809
+ // showBanner() also tears down these four overlays unconditionally. That is
16810
+ // right for a gesture-driven caller and wrong for a timer: firing it while
16811
+ // the reader has a grip, an insert bubble, the table edge menu or the H▾
16812
+ // dropdown open closes it under them at a moment nothing on screen
16813
+ // explains. So the flush waits for those too (re-review G6).
16814
+ function noticeTeardownWouldDisrupt() {
16815
+ if (toolbarMenuEl) return true;
16816
+ if (teEdgeMenu && !teEdgeMenu.hidden) return true;
16817
+ if (rowGrip && !rowGrip.hidden) return true;
16818
+ if (colGrip && !colGrip.hidden) return true;
16819
+ if (rowInsertBubble && !rowInsertBubble.hidden) return true;
16820
+ if (colInsertBubble && !colInsertBubble.hidden) return true;
16821
+ return false;
16822
+ }
16823
+ function noteDrawioNotice(message) {
16824
+ if (activeBanner || noticeTeardownWouldDisrupt()) {
16825
+ drawioDeferredNotice = { message: message, at: Date.now() };
16826
+ return;
16827
+ }
16828
+ drawioDeferredNotice = null;
16829
+ showBanner(message, null, null);
16830
+ }
16831
+ function flushDrawioNotice() {
16832
+ if (!drawioDeferredNotice) return;
16833
+ if (Date.now() - drawioDeferredNotice.at > DRAWIO_NOTICE_TTL_MS) {
16834
+ drawioDeferredNotice = null; // stale: it no longer describes the page
16835
+ return;
16836
+ }
16837
+ if (activeBanner || noticeTeardownWouldDisrupt()) return;
16838
+ const m = drawioDeferredNotice.message;
16839
+ drawioDeferredNotice = null;
16840
+ showBanner(m, null, null);
16841
+ }
16842
+ const DRAWIO_PAGE_GONE_MSG =
16843
+ '目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
16844
+ // Fix round 3 (re-review2 H3 / Q1 ruling). Annotations are reading-session
16845
+ // scratch — lib/md2doc.js keeps them in a WeakMap keyed by the source node
16846
+ // and says so ("In-memory only — gone on reload"; nothing writes them to
16847
+ // localStorage or to disk) — but they are still the reader's own work, and
16848
+ // a re-bake changes the geometry they were drawn over, so carrying them
16849
+ // across would place marks on shapes that moved. They are dropped, and the
16850
+ // reader is told which diagram lost them and why.
16851
+ const DRAWIO_ANNO_DROPPED_MSG =
16852
+ '這張 drawio 圖在磁碟上被改過,已重新繪製;畫在舊版上的註記無法對應到新的圖形,已移除。';
16853
+
16854
+ // What "never yank focus out from under the user" actually has to mean here.
16855
+ //
16856
+ // An EDITING SURFACE — a contenteditable, a raw-source textarea, an input —
16857
+ // is something the user is working IN; replacing the node under it destroys
16858
+ // work in progress and is never acceptable for a refresh nobody asked for.
16859
+ // Anything else that merely happens to hold focus is not: MEASURED — a
16860
+ // click on the multi-page navigation leaves that <button> as
16861
+ // document.activeElement, and it sits inside the very block a re-bake
16862
+ // replaces. Treating that as "the user is busy here" means the reader who
16863
+ // just picked a page, alt-tabbed to Draw.io and edited the file comes back
16864
+ // to a diagram that (with fix round 1's retry in place) refuses to update
16865
+ // for the whole session instead of losing the update once. So the guard is
16866
+ // scoped to surfaces, and a focused non-surface is allowed to be replaced.
16867
+ function focusIsEditingSurface(node) {
16868
+ if (!node) return false;
16869
+ if (node.isContentEditable) return true;
16870
+ const tag = node.tagName;
16871
+ return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT';
16872
+ }
16873
+
16874
+ // Is the user editing inside a block that carries a diagram we may be about
16875
+ // to replace? Checked BEFORE the fetch as well as per block after it: with
16876
+ // F2's retry in place an unconditional fetch would otherwise re-run a full
16877
+ // headless-Chromium bake every 10 seconds for as long as that editor stays
16878
+ // open. G1a is what makes this pre-check and the per-block skip agree: both
16879
+ // now ask drawioBoxIn() about the same live node.
16880
+ function drawioBlockHoldsEditingFocus() {
16881
+ const ae = document.activeElement;
16882
+ if (!ae || !contentEl.contains(ae)) return false;
16883
+ if (!focusIsEditingSurface(ae)) return false;
16884
+ const kids = contentEl.children;
16885
+ for (let i = 0; i < kids.length; i++) {
16886
+ if (!kids[i].contains(ae)) continue;
16887
+ return !!drawioBoxIn(kids[i]);
16888
+ }
16889
+ return false;
16890
+ }
16891
+
16892
+ async function refreshStaleDrawio(token) {
16893
+ if (drawioRefreshInFlight) return;
16894
+ // Review question Q2 / G1b: the cheap "there is nothing on screen this
16895
+ // could possibly change" and "the user is in the middle of something"
16896
+ // answers are given BEFORE the fetch, so a condition that can hold
16897
+ // indefinitely never costs a bake. None of them acks — nothing was
16898
+ // applied, so the next tick must ask again.
16899
+ if (!contentEl.querySelector('.drawio')) return;
16900
+ if (!domMatchesLastRender()) return;
16901
+ if (drawioBlockHoldsEditingFocus()) return;
16902
+ // Fix round 2 (re-review G7): a block drag in flight addresses every block
16903
+ // by ordinal across .content. applyPatch()/applyFullRender() cancel it
16904
+ // because they are gesture-driven and the gesture is already over; a
16905
+ // background timer has no such licence, so it waits the drag out instead.
16906
+ // Free and self-clearing — a drag is a pointer gesture, it cannot last.
16907
+ if (blockDragState) return;
16908
+ // Fix round 3 (re-review2 H1): a `.drawio` inside a table cell makes the
16909
+ // whole TABLE block replaceable by this path (verified by render — round
16910
+ // 2's report claimed the opposite). A live row/column pointer gesture
16911
+ // addresses rows and cells inside that table, so the refresh waits it out
16912
+ // rather than cancelling it from a timer — the same construction, and the
16913
+ // same reasoning, as `blockDragState` just above.
16914
+ if (tePointer) return;
16915
+ if (Date.now() < drawioNextAttemptAt) return;
16916
+ drawioRefreshInFlight = true;
16917
+ // Fix round 2 (re-review G8): without a deadline, one request that never
16918
+ // settles (a server wedged inside a bake — bakeDrawioSvg() has no
16919
+ // per-page timeout of its own) leaves this flag true forever, every later
16920
+ // {stale:true} returns at the line above, and the whole feature is
16921
+ // silently and permanently dead until the user reloads, with no signal at
16922
+ // all. An abort turns that into an ordinary paid bail: backed off, retried.
16923
+ const ctl = (typeof AbortController === 'function') ? new AbortController() : null;
16924
+ const abortTimer = ctl
16925
+ ? setTimeout(function () { try { ctl.abort(); } catch (e) { /* already settled */ } },
16926
+ DRAWIO_FETCH_TIMEOUT_MS)
16927
+ : null;
16928
+ let paidForAFetch = false;
16929
+ let acked = false;
16930
+ try {
16931
+ const startContent = lines.join('\n');
16932
+ let res;
16933
+ paidForAFetch = true;
16934
+ try {
16935
+ const init = {
16936
+ method: 'POST', headers: { 'content-type': 'application/json' },
16937
+ body: JSON.stringify({ fileId: ED.fileId, content: startContent }),
16938
+ };
16939
+ if (ctl) init.signal = ctl.signal;
16940
+ res = await fetch('/api/render', init);
16941
+ } catch (e) { return; }
16942
+ if (!res.ok) return;
16943
+ let j;
16944
+ try { j = await res.json(); } catch (e) { return; }
16945
+ if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) return;
16946
+ // A real edit may have landed (and its own rerenderAll() already read
16947
+ // the file fresh) while this request was in flight — `lines` no
16948
+ // longer matches what this response was computed from, so there is
16949
+ // nothing left to apply; the document already shows current content.
16950
+ if (lines.join('\n') !== startContent) return;
16951
+ if (!Array.isArray(lastParts) || lastParts.length !== j.parts.length ||
16952
+ !Array.isArray(blocks) || blocks.length !== j.blocks.length) return;
16953
+ // Same precondition applyPatch()'s own plan requires (see
16954
+ // domMatchesLastRender()'s comment): several list gestures mutate
16955
+ // .content's child list directly, between renders, without going
16956
+ // through a commit yet. If the DOM does not positionally match
16957
+ // `lastParts` right now, a mid-gesture edit is in flight — bail rather
16958
+ // than swap the wrong child. Re-read after the await, because the
16959
+ // pre-fetch check above cannot speak for the time the render took; same
16960
+ // for the block drag.
16961
+ if (!domMatchesLastRender()) return;
16962
+ if (blockDragState) return;
16963
+ if (tePointer) return;
16964
+ // Every block this refresh was responsible for has to land before the
16965
+ // server is told the tab is up to date. One skipped block means the
16966
+ // user is still looking at stale bytes, which is exactly the state F2
16967
+ // says must keep being retried.
16968
+ let applied = true;
16969
+ for (let i = 0; i < j.parts.length; i++) {
16970
+ const el = contentEl.children[i];
16971
+ const oldBoxes = drawioBoxesIn(el);
16972
+ // The prefilter is a superset test and decides nothing: it only says
16973
+ // "parsing this part might be worth it".
16974
+ if (!oldBoxes.length && !DRAWIO_TEXT_HINT.test(j.parts[i])) continue;
16975
+ const tpl = parsePartMarkup(j.parts[i]);
16976
+ const newBoxes = drawioBoxesIn(tpl.content);
16977
+ // Neither side holds a real diagram — the hint matched text that only
16978
+ // quotes this markup (a fenced code block, a table cell). Not a
16979
+ // candidate for anything, by either criterion.
16980
+ if (!oldBoxes.length && !newBoxes.length) continue;
16981
+ // Every diagram in the block is compared, and the bake is identical:
16982
+ // nothing to do. This is what keeps one external write from re-swapping
16983
+ // every OTHER diagram on the page (G4) — while H2 is why it has to be
16984
+ // every box and not just the first.
16985
+ if (drawioBlockFingerprint(oldBoxes) === drawioBlockFingerprint(newBoxes)) continue;
16986
+
16987
+ if (!el) { applied = false; continue; }
16988
+ // Never destroy an editor the user has open in this block — see
16989
+ // focusIsEditingSurface() for why it is surfaces and not "anything
16990
+ // focused". Not applied, so not acked: the next tick tries again, and
16991
+ // because the pre-fetch guard asks drawioBoxIn() about the same live
16992
+ // node, that next tick bails BEFORE paying for a render.
16993
+ if (el.contains(document.activeElement) && focusIsEditingSurface(document.activeElement)) {
16994
+ applied = false; continue;
16995
+ }
16996
+ // Fix round 2 (re-review G7): `gutterMenu` and `insertMenu` are ONE
16997
+ // shared node each, appended as a CHILD of whichever block they are
16998
+ // open for. Replacing that block detaches the singleton with no close
16999
+ // path run, leaving `gutterMenuBlockEl`/`insertMenuBlockEl` pointing
17000
+ // at a node no future click can reach — the exact hazard
17001
+ // applyPatch()/applyFullRender() call these two for. Scoped to the
17002
+ // block actually being replaced rather than called unconditionally
17003
+ // the way those two do, and the reason is the one F5 turns on: they
17004
+ // run on a user gesture that is already over, this runs on a 10s
17005
+ // timer, and closing a menu the reader has open on an UNRELATED block
17006
+ // would be a fresh instance of exactly the defect F5 closed.
17007
+ if (gutterMenuBlockEl === el) closeGutterMenu();
17008
+ if (insertMenuBlockEl === el) closeInsertMenu();
17009
+ // Fix round 3 (re-review2 H1). Round 2's report said 「一個 drawio 區塊
17010
+ // 永遠不是表格」 and refused the table resets on that basis. The claim is
17011
+ // FALSE — rendering `| ![d](d.drawio) | x |` yields a
17012
+ // `data-block-type="table"` block whose first cell holds the box, so
17013
+ // this path really can replace a whole table. The grips and the edge
17014
+ // menu live on document.body, so the swap does not detach them: they
17015
+ // stay visibly floating while `teMenuTableEl` / `teMenuRowEl` /
17016
+ // `gripRowTableEl` / `gripRowEl` / `gripColTableEl` point into a tree
17017
+ // nothing can reach. Scoped exactly like the two menus above, and for
17018
+ // the same reason. (A live row/column POINTER gesture is not handled
17019
+ // here at all — `if (tePointer) return;` above waits it out rather
17020
+ // than killing it from a timer, the same construction `blockDragState`
17021
+ // uses. The insert bubbles hold no element reference of their own —
17022
+ // hideTableInsertBubbles() only flips two `hidden` flags — so nothing
17023
+ // of theirs can dangle; the next mousemove re-derives them.)
17024
+ if ((teMenuTableEl && el.contains(teMenuTableEl)) ||
17025
+ (teMenuRowEl && el.contains(teMenuRowEl))) hideTableEdgeMenu();
17026
+ if ((gripRowTableEl && el.contains(gripRowTableEl)) ||
17027
+ (gripRowEl && el.contains(gripRowEl)) ||
17028
+ (gripColTableEl && el.contains(gripColTableEl))) hideTableGrips();
17029
+ // A non-surface node inside this block (the roving-tabindex holder of
17030
+ // a block selection, a page button just clicked) is about to stop
17031
+ // existing. Put focus back on the block wrapper afterwards when the
17032
+ // wrapper is a legitimate focus holder, so a keyboard user keeps
17033
+ // their place instead of being dropped at <body>.
17034
+ const refocusWrapper = el.contains(document.activeElement) && el.hasAttribute('tabindex');
17035
+
17036
+ // Ruling B2-2 (c): preserve the reader's currently-open page across
17037
+ // the swap, falling back to the first page with a visible banner when
17038
+ // it genuinely no longer exists. resolveDrawioLanding() (near the top
17039
+ // of this file, unit-tested) owns the "is a page name an identity
17040
+ // here at all" question — see its comment.
17041
+ //
17042
+ // Fix round 3 (re-review2 H2, second half): captured for EVERY box in
17043
+ // the block, not just the first, so a second multi-page diagram in one
17044
+ // block keeps the reader's page too.
17045
+ const oldLandings = oldBoxes.map(function (box) {
17046
+ if (!box.hasAttribute('data-drawio-pages')) return null;
17047
+ let names = [];
17048
+ try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
17049
+ catch (e2) { names = []; }
17050
+ if (!Array.isArray(names) || names.length < 2) return null;
17051
+ const pages = box.querySelectorAll('.drawio-page');
17052
+ const activePage = box.querySelector('.drawio-page:not([hidden])');
17053
+ return {
17054
+ names: names,
17055
+ activeIdx: activePage ? Array.prototype.indexOf.call(pages, activePage) : -1,
17056
+ };
17057
+ });
17058
+
17059
+ // Fix round 3 (re-review2 H3, Q1 ruling). The reader's annotations are
17060
+ // drawn INTO this block's `.drawio-page` and cannot be carried onto a
17061
+ // re-baked diagram whose geometry is different — but dropping the
17062
+ // user's own work silently is not something this codebase does. The
17063
+ // unrelated-write case never reaches here at all (drawioPageMarkup()
17064
+ // normalises the annotation subtree out of the fingerprint, so such a
17065
+ // block compares equal and is not swapped); what is left is the case
17066
+ // where the annotated diagram's OWN bytes changed, and that one is
17067
+ // told. Same principle as B2-P1: the two outcomes must not both be
17068
+ // silent.
17069
+ const hadAnnotations = !!(el.querySelector && el.querySelector('.anno-inline-wrap'));
17070
+
17071
+ const fresh = tpl.content.firstElementChild;
17072
+ if (!fresh) { applied = false; continue; }
17073
+ armEditables(tpl.content);
17074
+ // Best-effort carry-over of the wrapper's own visual state. Never
17075
+ // reaches into blockSelection / applySelectionClasses() here — that
17076
+ // call also moves focus, which a background refresh must never do.
17077
+ if (el.classList.contains('ed-selected')) fresh.classList.add('ed-selected');
17078
+ if (el.hasAttribute('tabindex')) fresh.setAttribute('tabindex', el.getAttribute('tabindex'));
17079
+
17080
+ el.replaceWith(fresh);
17081
+ lastParts[i] = j.parts[i];
17082
+ // preventScroll: this is a background refresh — .focus()'s default
17083
+ // scroll-into-view would yank the reader's viewport to a block they
17084
+ // merely left focused before scrolling away.
17085
+ if (refocusWrapper && fresh.focus) fresh.focus({ preventScroll: true });
17086
+ if (hadAnnotations) noteDrawioNotice(DRAWIO_ANNO_DROPPED_MSG);
17087
+
17088
+ // Fix round 1 (review F5 second half): the page landing and its
17089
+ // notice happen whether or not the page-navigation hook exists.
17090
+ // They used to sit INSIDE the hook check, so a document that was
17091
+ // single-page when the tab opened (the hook is gated on that, by a
17092
+ // byte-exact guard test) lost the page preservation AND the
17093
+ // "your page is gone" notice together — both branches of Ruling
17094
+ // B2-P1 going silent at once. Only the navigation rebuild itself
17095
+ // genuinely depends on the hook.
17096
+ if (window.__md2docInitDrawioSheetBar) {
17097
+ try { window.__md2docInitDrawioSheetBar(fresh); } catch (e4) { /* navigation only */ }
17098
+ }
17099
+ const freshBoxes = drawioBoxesIn(fresh);
17100
+ // Align old boxes to new ones BY POSITION, and only when the block
17101
+ // still holds the same number of diagrams. A block whose box count
17102
+ // changed has had one reference stop (or start) being bakeable, and
17103
+ // position no longer identifies anything — landing every box on the
17104
+ // baked default is then the only answer we can stand behind. Claiming
17105
+ // a page deletion there would be the same false-banner mistake F6 was
17106
+ // about, so that case is deliberately quiet.
17107
+ if (freshBoxes.length !== oldBoxes.length) continue;
17108
+ for (let b = 0; b < freshBoxes.length; b++) {
17109
+ const prev = oldLandings[b];
17110
+ if (!prev) continue; // was not a multi-page box
17111
+ const box = freshBoxes[b];
17112
+ if (!box.hasAttribute('data-drawio-pages')) continue;
17113
+ let newNames = [];
17114
+ try { newNames = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
17115
+ catch (e3) { newNames = []; }
17116
+ if (!Array.isArray(newNames)) newNames = [];
17117
+ const newPages = box.querySelectorAll('.drawio-page');
17118
+ const landing = resolveDrawioLanding(prev.names, prev.activeIdx, newNames);
17119
+ // Review Q1: clamp. `data-drawio-pages` and the .drawio-page children
17120
+ // are emitted together by lib/md2doc.js so they cannot disagree
17121
+ // today, but an out-of-range index here hides EVERY page and paints a
17122
+ // blank box — one line is cheaper than that failure.
17123
+ let landOn = landing.landOn;
17124
+ if (!(landOn >= 0) || landOn >= newPages.length) landOn = 0;
17125
+ for (let p = 0; p < newPages.length; p++) newPages[p].hidden = p !== landOn;
17126
+ const bar = box.querySelector('.drawio-sheetbar');
17127
+ if (bar) {
17128
+ bar.querySelectorAll('button').forEach(function (btn, bidx) {
17129
+ btn.setAttribute('aria-pressed', bidx === landOn ? 'true' : 'false');
17130
+ });
17131
+ }
17132
+ if (landing.gone) noteDrawioNotice(DRAWIO_PAGE_GONE_MSG);
17133
+ }
17134
+ }
17135
+ if (applied && typeof token === 'string' && token !== '') {
17136
+ drawioAckPending = token;
17137
+ acked = true;
17138
+ }
17139
+ } finally {
17140
+ if (abortTimer) clearTimeout(abortTimer);
17141
+ drawioRefreshInFlight = false;
17142
+ if (acked) {
17143
+ drawioBailStreak = 0;
17144
+ drawioNextAttemptAt = 0;
17145
+ } else if (paidForAFetch) {
17146
+ drawioBailStreak += 1;
17147
+ const wait = Math.min(DRAWIO_BACKOFF_BASE_MS * Math.pow(2, drawioBailStreak),
17148
+ DRAWIO_BACKOFF_CAP_MS);
17149
+ drawioNextAttemptAt = Date.now() + wait;
17150
+ }
17151
+ }
17152
+ }
17153
+
14940
17154
  setInterval(() => {
17155
+ // A notice this refresh could not show earlier because the slot was
17156
+ // taken (see noteDrawioNotice) gets its next chance here, on the same
17157
+ // clock — or is dropped if it has gone stale.
17158
+ flushDrawioNotice();
14941
17159
  // /api/ping requires content-type: application/json like the other
14942
17160
  // state-changing POST routes (415 otherwise) — see server.js's CORS
14943
17161
  // defense. A body is included so the header is meaningful, not just
14944
17162
  // present on an otherwise-empty request.
17163
+ //
17164
+ // v3.4.0 batch2 Task 6 (Ruling B2-2): `fileId` + this tab's own
17165
+ // `drawioClientId` let server.js answer whether any `.drawio`/`.xml`
17166
+ // this document references changed on disk since the bytes THIS TAB is
17167
+ // showing — see its own comment on /api/ping. A 204 (no body) means
17168
+ // "nothing stale", the same outcome this heartbeat always had; a 200
17169
+ // with `{stale:true, token}` is new and triggers a background refresh.
17170
+ // `drawioAck` carries back the token of the last refresh that actually
17171
+ // landed, and is the ONLY thing that advances this tab's baseline.
17172
+ // Every failure mode (network error, non-200/204 status, malformed
17173
+ // JSON) is swallowed exactly like the old bare ping already swallowed
17174
+ // everything — a missed signal just waits for the next 10s tick, which
17175
+ // is the accepted worst-case latency for this whole feature.
17176
+ const ack = drawioAckPending;
17177
+ drawioAckPending = null;
14945
17178
  fetch('/api/ping', {
14946
- method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
17179
+ method: 'POST', headers: { 'content-type': 'application/json' },
17180
+ body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
17181
+ }).then((res) => {
17182
+ if (res.status !== 200) return null;
17183
+ return res.json().catch(() => null);
17184
+ }).then((j) => {
17185
+ if (j && j.stale) refreshStaleDrawio(j.token).catch(() => {});
14947
17186
  }).catch(() => {});
14948
17187
  }, 10000);
14949
17188
 
14950
17189
  armEditables(contentEl);
14951
17190
  wireBlockSelection();
17191
+
17192
+ // ── v3.4.0 batch3 Task 6: the wavedrom editing surface ─────────────────
17193
+ //
17194
+ // Entry is a hover affordance over an already-rendered diagram, and the
17195
+ // affordance itself lives on document.body — not in .content. Everything in
17196
+ // .content is read back as this tab's own render (see `lastParts` above) and
17197
+ // the WYSIWYG path turns it into markdown, so a control parked in there would
17198
+ // travel into the user's file. The table grips already sit on body for the
17199
+ // same reason; this follows them rather than inventing a second policy.
17200
+ //
17201
+ // The block is NOT parsed here. Ruling P3-6 parses at Edit-click time, so a
17202
+ // document with forty diagrams pays nothing for thirty-nine of them, and a
17203
+ // block whose WaveJSON cannot be read still gets an editor — one that says
17204
+ // what the parser said and where — instead of a button that silently does
17205
+ // nothing.
17206
+ let waveDiagramEl = null;
17207
+ let waveEditor = null;
17208
+ // The seam Task 7 writes through: which block the open editor came from, and
17209
+ // the line range of the fence's BODY (the fence markers themselves are not
17210
+ // the editor's to rewrite). `lastGesture` is refreshed on every gesture, not
17211
+ // accumulated — wave-ui computes one patch per gesture precisely so a
17212
+ // write-back never has to replay a session's worth of them.
17213
+ //
17214
+ // Task 7's own fields, and why each is here rather than derived:
17215
+ // ops how many commits this session has pushed onto the document
17216
+ // stack. Escape reverses exactly this many — see
17217
+ // finishWaveSession().
17218
+ // baseLines `lines` as it stood when the editor opened, and
17219
+ // baseBlocks `blocks` likewise. Escape restores both BY VALUE instead of
17220
+ // trusting the reversal's arithmetic to land back where it
17221
+ // started; the two are the same answer, and one of them cannot
17222
+ // be off by a line.
17223
+ // baseSource the block body the store was opened over, i.e. what `lines`
17224
+ // holds again once an Escape has run. The stash's staleness
17225
+ // guard compares against it.
17226
+ // unwritten this session has a gesture the file could not express (the
17227
+ // document-level flag `waveUnwrittenEdit` is the same fact; the
17228
+ // flag is what documentIsDirty() reads, this is what the
17229
+ // session's own bookkeeping reads).
17230
+ // baseDepth how deep the document's undo stack was at open — the one
17231
+ // baseline both of waveDiscardIsSafe()'s questions are asked
17232
+ // against. See that function for the measured sequence that
17233
+ // killed the arithmetic it replaces (fix 2 / MUST-FIX 1).
17234
+ // baseRedoTail the redo branch that existed before the session (fix 1 /
17235
+ // F3). The session's first commit clears it like any other edit;
17236
+ // a session that is DISCARDED has to hand it back, or the user
17237
+ // silently loses a redo to a document that ends up
17238
+ // byte-identical.
17239
+ let waveSeam = null;
17240
+ // Gestures that arrived with no editor left to own them. Zero is the
17241
+ // invariant, not a tolerance: a paint that lands after the overlay came down
17242
+ // is one the user cancelled, applied to a store nobody can see. It used to be
17243
+ // reachable — an Escape pressed mid-drag left the drag armed and the document
17244
+ // listeners attached, so the eventual release still ran the paint. Counted
17245
+ // rather than merely guarded against, because "it cannot happen" is what the
17246
+ // last version of this said.
17247
+ let waveStrayGestures = 0;
17248
+ const waveEditBtn = document.createElement('button');
17249
+ waveEditBtn.type = 'button';
17250
+ waveEditBtn.className = 'ed-wave-edit-btn';
17251
+ waveEditBtn.textContent = '編輯波形';
17252
+ waveEditBtn.title = '用圖形介面編輯這張時序圖';
17253
+ waveEditBtn.hidden = true;
17254
+ document.body.appendChild(waveEditBtn);
17255
+
17256
+ /**
17257
+ * Whether the wave editor is the modal surface right now.
17258
+ *
17259
+ * The one question every global handler in this file asks about it, and it is
17260
+ * deliberately a question about STATE and not about the event.
17261
+ *
17262
+ * The previous version asked "did this event land inside the overlay?", and
17263
+ * that cannot answer "who owns the screen" — the answer has to be true before
17264
+ * any event arrives. MEASURED: right after the overlay opens,
17265
+ * `document.activeElement` is `document.body`, so every key targeted body and
17266
+ * the editor behind the modal still owned it. Three things fell out of that
17267
+ * one wrong question: Ctrl+Z in the unreadable panel rolled back the markdown
17268
+ * document behind the modal, `Tab` was swallowed by the "nothing focused"
17269
+ * branch so none of the dialog's controls could be reached by keyboard, and
17270
+ * the block-selection delete path stayed live for a body target.
17271
+ *
17272
+ * A target test also means a LIST — one `closest()` per event someone thought
17273
+ * of — and `drop` was the one nobody thought of: a PNG dragged onto the panel
17274
+ * went into the document behind it. A state question has no list.
17275
+ *
17276
+ * A function declaration on purpose: it is hoisted, so the listeners
17277
+ * registered far above can call it, and `waveEditor` is a `let` in this same
17278
+ * scope that is initialised long before any event fires.
17279
+ */
17280
+ function waveEditorIsModal() {
17281
+ return waveEditor !== null;
17282
+ }
17283
+
17284
+ /**
17285
+ * The one gesture the modal does not get to swallow.
17286
+ *
17287
+ * Kept beside the predicate rather than inlined at the call site, because
17288
+ * "which gestures the modal owns" is part of the same answer as "who owns the
17289
+ * screen", and the two belong in one place. Spelled exactly as the save branch
17290
+ * — which is ~1230 lines ABOVE this, in the main `keydown` listener (measured:
17291
+ * this declaration is at 16989, that branch at 15732) — so the two cannot drift
17292
+ * into disagreeing about what a save keystroke is. The direction matters: a
17293
+ * reader who goes looking DOWNWARD finds only the wave wiring, concludes this
17294
+ * comment is stale, and edits one predicate without the other, which is exactly
17295
+ * how the modal starts swallowing Ctrl+S again.
17296
+ *
17297
+ * ── Why this is the only exemption, as of v3.4.0 batch3 ─────────────────
17298
+ *
17299
+ * The question was asked of every branch of that listener rather than answered
17300
+ * for Ctrl+S alone, and the answer is recorded here so the next person adding
17301
+ * a global shortcut can see that it was asked:
17302
+ *
17303
+ * - Everything about EDITING the document behind the modal — the toolbar's
17304
+ * keyboard mode, block selection's Tab/Delete/Backspace, the burst
17305
+ * short-circuits, undo/redo, Escape — is exactly what the modal is for.
17306
+ * Those stay swallowed.
17307
+ * - Escape, Tab and undo/redo are additionally CLAIMED by the overlay's own
17308
+ * capture-phase handler, which stops them before they reach here, so the
17309
+ * gate is their second line and not their first.
17310
+ * - `Ctrl+P` / `Ctrl+F` / `Ctrl+A` / `F5` have no handler in this file at
17311
+ * all, so the gate cannot swallow them: it is a bare `return` with no
17312
+ * `preventDefault()`, and the browser's own behaviour is untouched.
17313
+ * - `Ctrl+S` is the only global gesture this file implements that is not
17314
+ * about the covered document. It is the reflex that protects work, the
17315
+ * wave editor has nothing of its own to save in this task, and it is the
17316
+ * only producer of the 409 that raises the disk-conflict banner.
17317
+ *
17318
+ * So: a new global shortcut added below the gate is DEAD while the overlay is
17319
+ * up. If it is about the document, that is correct. If it is not — a help
17320
+ * panel, a command palette, anything the user reaches for regardless of what
17321
+ * is on screen — it belongs in this function.
17322
+ */
17323
+ function isSaveGesture(e) {
17324
+ return (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's';
17325
+ }
17326
+
17327
+ function hideWaveEditBtn() {
17328
+ // Already hidden is the overwhelmingly common case: this is called from a
17329
+ // CAPTURE-phase `mouseover` on the document (once per element the pointer
17330
+ // crosses) and a capture-phase `scroll` on the window (inner scrollers
17331
+ // included). Writing `hidden` unconditionally made every pointer move over
17332
+ // the page a DOM write on document.body's own subtree; the early return
17333
+ // makes both free.
17334
+ if (waveDiagramEl === null && waveEditBtn.hidden) return;
17335
+ waveDiagramEl = null;
17336
+ waveEditBtn.hidden = true;
17337
+ }
17338
+
17339
+ function showWaveEditBtnFor(el) {
17340
+ // Same diagram, already showing: nothing to write. The listener that calls
17341
+ // this fires once per element the pointer crosses, and a diagram is a tree
17342
+ // of them.
17343
+ if (waveDiagramEl === el && !waveEditBtn.hidden) return;
17344
+ const r = el.getBoundingClientRect();
17345
+ if (r.width === 0 && r.height === 0) return hideWaveEditBtn();
17346
+ waveDiagramEl = el;
17347
+ waveEditBtn.hidden = false;
17348
+ // Top-right of the diagram, nudged inside it so it never hangs over the
17349
+ // block gutter on the left or off the viewport on the right — and never
17350
+ // above the toolbar's own band, which would put this button UNDER the bar
17351
+ // (z-index 12 against 101) at every scroll position where the diagram's
17352
+ // top edge has gone past it. See `toolbarTopInset()` for the measurement:
17353
+ // the affordance was drawn, was invisible, was not clickable, and was the
17354
+ // feature's only entry point.
17355
+ waveEditBtn.style.top =
17356
+ Math.max(toolbarTopInset() + 4, r.top + 6) + 'px';
17357
+ waveEditBtn.style.left = Math.max(4,
17358
+ Math.min(r.right - waveEditBtn.offsetWidth - 6,
17359
+ window.innerWidth - waveEditBtn.offsetWidth - 6)) + 'px';
17360
+ }
17361
+
17362
+ // The class the diagram init hook tags every rendered display div with; the
17363
+ // id prefix is the same signal for a page that predates the class.
17364
+ const WAVE_DIAGRAM_SEL = '.wavedrom-diagram, [id^="WaveDrom_Display_"]';
17365
+
17366
+ function waveDiagramAt(node) {
17367
+ if (node === null || node === undefined || typeof node.closest !== 'function') return null;
17368
+ return node.closest(WAVE_DIAGRAM_SEL);
17369
+ }
17370
+
17371
+ /**
17372
+ * The rendered diagram INSIDE a block, if it holds one.
17373
+ *
17374
+ * `waveDiagramAt()` answers the pointer's question — "what is under this
17375
+ * element?" — by walking UP. The keyboard's question is the other direction:
17376
+ * the user is standing on a whole block and wants to know whether it is a
17377
+ * waveform. Same selector, deliberately shared, so the affordance a mouse
17378
+ * user gets and the one a keyboard user gets can never come to disagree
17379
+ * about which blocks are editable.
17380
+ *
17381
+ * A function declaration rather than an arrow const: the keydown listener
17382
+ * that calls it is registered hundreds of lines above this point.
17383
+ */
17384
+ function waveDiagramInside(blockEl) {
17385
+ if (blockEl === null || blockEl === undefined ||
17386
+ typeof blockEl.querySelector !== 'function') return null;
17387
+ return blockEl.querySelector(WAVE_DIAGRAM_SEL);
17388
+ }
17389
+
17390
+ document.addEventListener('mouseover', (ev) => {
17391
+ if (waveEditor !== null) return;
17392
+ const t = ev.target;
17393
+ if (t !== null && typeof t.closest === 'function' && t.closest('.ed-wave-edit-btn')) return;
17394
+ const diagram = waveDiagramAt(t);
17395
+ if (diagram === null) { hideWaveEditBtn(); return; }
17396
+ showWaveEditBtnFor(diagram);
17397
+ }, true);
17398
+ window.addEventListener('scroll', () => { if (waveEditor === null) hideWaveEditBtn(); }, true);
17399
+
17400
+ // The fence BODY of one block: its source lines with the opening fence, and
17401
+ // the closing fence when there is one, taken off. An unterminated fence at the
17402
+ // end of the document has no closing line to drop, and dropping the last line
17403
+ // anyway would silently eat a cycle of the user's diagram.
17404
+ function waveBodyOf(block) {
17405
+ const src = lines.slice(block.startLine - 1, block.endLine);
17406
+ let end = src.length;
17407
+ if (end > 1 && /^\s*(?:```+|~~~+)\s*$/.test(src[end - 1])) end--;
17408
+ return {
17409
+ text: src.slice(1, end).join('\n'),
17410
+ startLine: block.startLine + 1,
17411
+ endLine: block.startLine + end - 1,
17412
+ };
17413
+ }
17414
+
17415
+ async function openWaveEditor(diagram) {
17416
+ const ui = window.md2docWaveUi;
17417
+ const mods = window.__md2docWave;
17418
+ // An older cached page that predates the injection must degrade to "the
17419
+ // button does nothing visible", never to a throw out of a click handler.
17420
+ if (!ui || !mods || !mods['wave-store.js'] || !mods['wave-codec.js'] ||
17421
+ !mods['wave-geometry.js']) return;
17422
+ if (waveEditor !== null) return;
17423
+ hideWaveEditBtn();
17424
+
17425
+ // Settle the document BEFORE reading a single line out of it. An open
17426
+ // burst or block editor is committed here, deliberately in front of the
17427
+ // overlay rather than behind it: the commit rewrites `lines` and can
17428
+ // re-render `.content`, and a line range read before that is stale the
17429
+ // moment it is written. A standing block selection goes too — the wave
17430
+ // editor is about to take the keyboard, and a selection the user can no
17431
+ // longer see is a selection they cannot mean.
17432
+ // fix 2 (R2): the return value is not decoration. When it is false an editor
17433
+ // is still open BEHIND the overlay, and the session's own Ctrl+S — which
17434
+ // runs `switchAwayFrom().then(ok => ok && save())` — then commits it,
17435
+ // pushing a NON-wave op inside the session. That op breaks both of the
17436
+ // session's bookkeeping assumptions at once: `seam.ops` no longer counts the
17437
+ // top of the stack (so a discard would reverse somebody else's edit — now
17438
+ // caught by waveDiscardIsSafe()'s depth question), and its line delta would
17439
+ // be attributed to the wave block by settleWaveBlocks(), which is F2's own
17440
+ // failure shape through a door F2 did not close. Every other caller in this
17441
+ // file already treats false as "do not proceed"; so does this one.
17442
+ // Was the keyboard standing ON this block when the editor opened? Asked
17443
+ // BEFORE `switchAwayFrom()`, because that can re-render `.content` and
17444
+ // detach the very elements the question is about.
17445
+ //
17446
+ // A state question, not a「which device opened it」one: the keyboard entry
17447
+ // above always satisfies it, and so does a mouse user who selected the
17448
+ // block first and then pressed the affordance — in both cases the editor
17449
+ // is about to take a keyboard that was somewhere visible, and closing owes
17450
+ // it back. When it is false (the ordinary hover-and-click), the keyboard
17451
+ // was on `document.body` before the overlay opened and this changes
17452
+ // nothing about where it ends up.
17453
+ const cameFromBlockSelection = blockSelection !== null &&
17454
+ typeof diagram.closest === 'function' &&
17455
+ selectionFocusBlockEl() === diagram.closest('.ed-block');
17456
+ if (!(await switchAwayFrom())) return;
17457
+ clearBlockSelection();
17458
+
17459
+ // The re-render `switchAwayFrom()` may have run replaces `.content`
17460
+ // wholesale, so the element that was hovered can be detached and its block
17461
+ // id can have shifted. Re-resolve both, and give up quietly if the diagram
17462
+ // is no longer on screen — the user hovers again, which is cheaper than
17463
+ // opening an editor over a block that is not the one they pointed at.
17464
+ if (!document.contains(diagram)) return;
17465
+ const blockEl = diagram.closest('.ed-block');
17466
+ if (blockEl === null) return;
17467
+ const id = Number(blockEl.getAttribute('data-block-id'));
17468
+ const block = blocks.find((b) => b.id === id);
17469
+ if (!block) return;
17470
+ const body = waveBodyOf(block);
17471
+
17472
+ // The seam is written only once the editor really exists. createWaveEditor
17473
+ // can throw (the `.content` mount guard is a throw by design), and a seam
17474
+ // left standing with no editor behind it is a lie the next gesture would
17475
+ // read.
17476
+ let editor = null;
17477
+ try {
17478
+ editor = ui.createWaveEditor({
17479
+ document: document,
17480
+ host: document.body,
17481
+ source: body.text,
17482
+ codec: mods['wave-codec.js'],
17483
+ geometry: mods['wave-geometry.js'],
17484
+ createStore: mods['wave-store.js'].createStore,
17485
+ // The store's own answer to「哪一條 lane 是哪一條」across one operation,
17486
+ // handed over rather than re-derived: the editor's cell cursor and its
17487
+ // selection are row indexes, and the patch planner reads the identical
17488
+ // function to decide which lane moved. Two implementations of that
17489
+ // question would let a mark paint one lane while the patch wrote
17490
+ // another.
17491
+ laneTrace: mods['wave-store.js'].laneTrace,
17492
+ wavedrom: (typeof WaveDrom !== 'undefined') ? WaveDrom : null,
17493
+ onGesture: (info) => {
17494
+ if (waveSeam === null) { waveStrayGestures++; return; }
17495
+ waveSeam.lastGesture = info;
17496
+ writeWaveGestureBack(info);
17497
+ },
17498
+ onClose: (reason) => {
17499
+ // Read the seam out and null BOTH before doing anything slow: the
17500
+ // predicate every global listener asks (`waveEditorIsModal()`) has to
17501
+ // answer "no" from this instant, and finishWaveSession() below awaits
17502
+ // a render. A seam still standing across that await is one a stray
17503
+ // late gesture could write through.
17504
+ const seam = waveSeam;
17505
+ waveEditor = null;
17506
+ waveSeam = null;
17507
+ finishWaveSession(seam, reason)
17508
+ // …and only then the keyboard, because the commit path RE-RENDERS
17509
+ // `.content` and every block element that existed when the overlay
17510
+ // was up is gone by the time that resolves. Focusing before the
17511
+ // render would put the cursor on a detached node — which is
17512
+ // `document.body` with extra steps.
17513
+ .then(() => { giveKeyboardBackToWaveBlock(seam); })
17514
+ .catch((err) => {
17515
+ console.error('md2doc: the wave editor could not settle its ' +
17516
+ 'write-back', err);
17517
+ });
17518
+ },
17519
+ });
17520
+ } catch (err) {
17521
+ waveEditor = null;
17522
+ waveSeam = null;
17523
+ throw err;
17524
+ }
17525
+ waveEditor = editor;
17526
+ waveSeam = { blockId: id, startLine: body.startLine, endLine: body.endLine,
17527
+ lastGesture: null, ops: 0, unwritten: false,
17528
+ blockStartLine: block.startLine, blockEndLine: block.endLine,
17529
+ baseSource: body.text, baseLines: lines, baseBlocks: blocks,
17530
+ baseDepth: stack.depth, baseRedoTail: stack.redoTail(),
17531
+ reselect: cameFromBlockSelection };
17532
+ // A fresh session cannot inherit the previous one's unwritten work: that
17533
+ // session is over, and whatever it could not express went with it.
17534
+ waveUnwrittenEdit = false;
17535
+ }
17536
+
17537
+ // ── Task 7: one gesture, one write-back ────────────────────────────────
17538
+ //
17539
+ // Not one write-back per SESSION. Task 5 measured the store's refusal rate
17540
+ // climbing with the number of structural operations stacked up before a
17541
+ // patch is asked for — 8.0% at 1-3 operations, 17.0% at 1-6, and an
17542
+ // independently generated corpus agreed on the shape (6.6% / 18.8%). Those
17543
+ // refusals are correct; they are what replaced a class of silent corruption.
17544
+ // But a refusal the user meets at the END of a session is one they have to
17545
+ // bisect out of their own work, so the patch is computed and applied on the
17546
+ // gesture that caused it. wave-ui already computes exactly one patch per
17547
+ // gesture for this reason; this is the other end of that seam.
17548
+ //
17549
+ // Everything here goes through commitRangeEdit() — the same helper every
17550
+ // other structural gesture in this file commits through — so the write-back
17551
+ // lands on the document undo stack like any other edit. `lines` is never
17552
+ // written directly. Two things fall out of that for free and are load-
17553
+ // bearing, not incidental: `stack.dirtyDepth` counts the waveform (so the
17554
+ // title dot, the save button and the beforeunload guard all see it without
17555
+ // knowing what a waveform is), and Escape can take the session back off the
17556
+ // stack rather than needing a stash of its own for the part that committed.
17557
+ //
17558
+ // What is deliberately NOT done here: re-rendering `.content`. The overlay
17559
+ // covers the document, a render replaces `.content` wholesale, and doing it
17560
+ // per paint would tear down and re-run WaveDrom on every gesture for a
17561
+ // picture nobody can see. `.content` therefore lags `lines` for exactly as
17562
+ // long as the modal is up, and the session's close settles it — a commit by
17563
+ // rendering, an Escape by putting `lines` back to what `.content` is still
17564
+ // showing.
17565
+ function writeWaveGestureBack(info) {
17566
+ const seam = waveSeam;
17567
+ if (seam === null || !info || !info.patch) return;
17568
+ if (info.patch.ok !== true) {
17569
+ waveRefuseGesture(seam, info.patch.reason);
17570
+ return;
17571
+ }
17572
+ const result = commitRangeEdit({ lines, blocks, stack },
17573
+ seam.startLine, seam.endLine, info.patch.text);
17574
+ if (result.op === null) {
17575
+ // `commitRangeEdit()` returns `op: null` for two different reasons, and
17576
+ // only one of them is "nothing to do": text that is already
17577
+ // byte-identical, and — tagged `refused` — a line range it will not touch
17578
+ // at all (an inverted one, i.e. a block that owns no source line).
17579
+ //
17580
+ // The `refused` arm is DEFENSIVE and is not known to be reachable from
17581
+ // here (fix 2 / R3, correcting an earlier claim that it was): the only
17582
+ // inverted seam this path can build comes from `waveBodyOf()` on an empty
17583
+ // fence body, and an empty wavedrom fence renders no `.wavedrom-diagram`,
17584
+ // so there is no hover affordance and no session. It is handled anyway
17585
+ // because the cost of being wrong about that is the silent drop this
17586
+ // whole layer exists to prevent, and the cost of the branch is three
17587
+ // lines.
17588
+ //
17589
+ // fix 1 (F6): the sentence that stood here said this branch catches "an
17590
+ // edit and its inverse inside one session". Re-derived, that is false
17591
+ // under per-gesture write-back — `lines` already holds the forward edit,
17592
+ // so the inverse produces a patch that DIFFERS from the current bytes and
17593
+ // pushes a real second op (MEASURED: paint then the overlay's own undo
17594
+ // gives `ops` 1 → 2, not `op === null`). The net-zero session is handled
17595
+ // further down instead, by unwinding the ops rather than by never having
17596
+ // pushed them.
17597
+ if (result.refused) {
17598
+ waveRefuseGesture(seam,
17599
+ '這個區塊的行範圍不合法(' + result.refused + '),所以沒有東西可以改');
17600
+ return;
17601
+ }
17602
+ seam.unwritten = false;
17603
+ waveUnwrittenEdit = false;
17604
+ setDirty();
17605
+ return;
17606
+ }
17607
+ lines = result.lines;
17608
+ // `blocks` is deliberately left alone until the session settles.
17609
+ // commitRangeEdit()'s shift anchor is the last block ENDING at or before
17610
+ // the edited range, and this range is the fence's BODY — strictly inside
17611
+ // the code block — so the anchor is the block BEFORE it and the shift would
17612
+ // move the wavedrom block's own startLine, which did not move. Leaving
17613
+ // `blocks` at its opening value keeps it merely STALE (every id present,
17614
+ // every span the one `.content` was rendered from) instead of wrong, and
17615
+ // nothing reads it while the modal owns the screen.
17616
+ //
17617
+ // fix 1 (F2): "the close settles it" is now true on BOTH close paths, not
17618
+ // only the one where the render succeeds — see settleWaveBlocks().
17619
+ seam.ops++;
17620
+ seam.endLine = seam.startLine + info.patch.text.split('\n').length - 1;
17621
+ seam.unwritten = false;
17622
+ waveUnwrittenEdit = false;
17623
+ // fix 1 (F5): a session that has come back to the bytes it started from
17624
+ // takes its own ops off the stack again, so the dirty dot goes out instead
17625
+ // of standing for the rest of the session over a byte-identical file.
17626
+ // MEASURED before this: paint, then the overlay's own undo — the document
17627
+ // was back to the original and the title stayed `● doc` permanently.
17628
+ if (bodyNow(seam) === seam.baseSource) unwindWaveOps(seam);
17629
+ setDirty();
17630
+ }
17631
+
17632
+ /** The block body as `lines` holds it right now. */
17633
+ function bodyNow(seam) {
17634
+ return lines.slice(seam.startLine - 1, seam.endLine).join('\n');
17635
+ }
17636
+
17637
+ /** One refusal, recorded and raised. The only producer of `waveUnwrittenEdit`. */
17638
+ function waveRefuseGesture(seam, reason) {
17639
+ // A refusal is not papered over and not rolled back. wave-ui has already
17640
+ // said so on the status line, which is where the user's eyes are, but a
17641
+ // status line is not a decision — this raises it as a banner naming the
17642
+ // lines it could not write and waits for the user to acknowledge it.
17643
+ seam.unwritten = true;
17644
+ waveUnwrittenEdit = true;
17645
+ setDirty();
17646
+ showWaveRefusal(seam, reason);
17647
+ }
17648
+
17649
+ /**
17650
+ * Take this session's own commits back off the document stack — bytes, stack
17651
+ * depth and redo branch — and say whether that was possible.
17652
+ *
17653
+ * What it does NOT give back is the SAVE MARKER. `push()` invalidates that
17654
+ * when history diverges past it, and a withdrawal cannot un-invalidate: the
17655
+ * ops that produced the saved bytes are gone from this stack whether or not
17656
+ * this session later changed its mind. MEASURED: edit, commit, Ctrl+S,
17657
+ * Ctrl+Z, open the editor, paint once, Escape, Ctrl+Y — the session is fully
17658
+ * withdrawn and the document is byte-identical to disk, and `●` still stands
17659
+ * until the next Ctrl+S. That is over-warning, which is the safe direction
17660
+ * and is deliberately not traded for a marker that could be wrong in the
17661
+ * other one.
17662
+ *
17663
+ * Returns false when it was NOT — see waveDiscardIsSafe() for the two
17664
+ * questions that decide it and for why neither is arithmetic over the dirty
17665
+ * depth any more. When it answers false a Ctrl+S landed in the middle (the
17666
+ * one gesture the modal deliberately does not swallow), or something other
17667
+ * than this session pushed, and `discardTop()` must not run: it would either
17668
+ * destroy the state disk was written from, or reverse somebody else's edit.
17669
+ *
17670
+ * The caller decides what to do about a false; this function never leaves the
17671
+ * stack half-unwound.
17672
+ */
17673
+ function unwindWaveOps(seam) {
17674
+ if (seam.ops === 0) return true;
17675
+ if (!waveDiscardIsSafe(stack, seam)) return false;
17676
+ for (let i = 0; i < seam.ops; i++) {
17677
+ if (stack.discardTop(lines) === null) break;
17678
+ }
17679
+ // fix 1 (F3): the session's first commit cleared the redo branch that
17680
+ // existed before it, and `discardTop()` has no way to put that back. A
17681
+ // session that is being withdrawn has to, or the comment below would be
17682
+ // claiming something that is measurably false.
17683
+ stack.setRedoTail(seam.baseRedoTail);
17684
+ // Restored BY VALUE rather than from discardTop()'s own returned arrays:
17685
+ // the two are the same answer, and only one of them can be off by a line.
17686
+ // discardTop() is still called for every op, because popping the stack is
17687
+ // the half `baseLines` cannot do.
17688
+ lines = seam.baseLines;
17689
+ blocks = seam.baseBlocks;
17690
+ seam.ops = 0;
17691
+ seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
17692
+ return true;
17693
+ }
17694
+
17695
+ /**
17696
+ * Make `blocks` agree with `lines` from the session's own arithmetic.
17697
+ *
17698
+ * Only ever needed because the session defers the block map to one render at
17699
+ * the end, and that render can fail. See shiftBlocksAfterBodyEdit()'s own
17700
+ * comment for what a stale `blocks` costs.
17701
+ */
17702
+ function settleWaveBlocks(seam) {
17703
+ blocks = shiftBlocksAfterBodyEdit(seam.baseBlocks, seam.blockId,
17704
+ lines.length - seam.baseLines.length);
17705
+ }
17706
+
17707
+ /**
17708
+ * Say, visibly, that a gesture could not be written — and name where.
17709
+ *
17710
+ * The status line inside the overlay already says it, and that is not
17711
+ * enough: a status line is read while you are looking for it, and this is
17712
+ * the one message in the feature that means "what you are looking at is not
17713
+ * what the file has". So it is raised as a banner — the same `.ed-conflict`
17714
+ * layer the disk-conflict notice uses, which is already painted ABOVE the
17715
+ * modal and already inside the dialog's focus trap (see `modalRoots()` in
17716
+ * wave-ui.js), so it is both visible and Tab-reachable without this having
17717
+ * to invent a second modal layer.
17718
+ *
17719
+ * Nothing is rewritten and nothing is rolled back: `store.toPatch()`'s
17720
+ * refusal travels to the user unchanged, which is what the whole codec layer
17721
+ * exists to make possible.
17722
+ *
17723
+ * ── One coordinate, in the frame the user is looking at (fix 1 / F4) ─────
17724
+ *
17725
+ * The first cut named two: the block's current document line range, and a
17726
+ * line number taken from `rewroteRange`'s offsets. The second was mapped
17727
+ * through the store's own `sourceText` — the body AS IT WAS WHEN THE EDITOR
17728
+ * OPENED, which is the only frame those offsets are exact in, since the store
17729
+ * is created once per session. That mapping was right and the REPORTING was
17730
+ * wrong: nothing in the sentence said the number belonged to a snapshot.
17731
+ * MEASURED after a rename plus two lane-adds — the banner said 「這個區塊自己的
17732
+ * 第 2 行」, which was the `clk` lane at open and is the just-added lane in the
17733
+ * file the user now has, with `clk` one line further down. A user who counts
17734
+ * two lines into the range the same sentence just gave them lands on the
17735
+ * wrong lane.
17736
+ *
17737
+ * It is dropped rather than labelled. A number that needs a paragraph of
17738
+ * frame-of-reference to be safe is not a coordinate, and the store's own
17739
+ * `reason` already names the path it is about — which is the thing that
17740
+ * survives a body whose line count has moved.
17741
+ */
17742
+ function showWaveRefusal(seam, reason) {
17743
+ const range = seam.startLine === seam.endLine
17744
+ ? ('第 ' + seam.startLine + ' 行')
17745
+ : ('第 ' + seam.startLine + '–' + seam.endLine + ' 行');
17746
+ // 知道了 is the confirmation the refusal waits for, and it is a SECOND
17747
+ // button rather than a relabelled ✕: the ✕ on this banner means the same
17748
+ // thing, but a dismiss glyph reads as "hide this" and the whole point of
17749
+ // this banner is that the user has registered what it says. Acknowledging
17750
+ // deliberately does NOT clear `waveUnwrittenEdit` — the work is still
17751
+ // missing from the file after the user has read about it, and the dirty
17752
+ // net is about the work, not about the notice.
17753
+ let el = null;
17754
+ el = showBanner(
17755
+ '這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
17756
+ '比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
17757
+ '(不含前後兩道圍欄)。原因:' + reason,
17758
+ '知道了',
17759
+ () => {
17760
+ if (el === null) return;
17761
+ el.remove();
17762
+ // Scoped to OUR element: a newer banner may have replaced this one on
17763
+ // screen between the refusal and the acknowledgement, and taking that
17764
+ // one down would hide something the user has not read.
17765
+ if (activeBanner === el) {
17766
+ activeBanner = null;
17767
+ activeBannerIsRefusal = false;
17768
+ activeBannerIsSwallow = false;
17769
+ activeBannerIsKeynavExit = false;
17770
+ }
17771
+ });
17772
+ }
17773
+
17774
+ /**
17775
+ * Settle a wave session that has just gone off screen.
17776
+ *
17777
+ * `reason` is wave-ui's, not inferred here: 'escape' means the user asked for
17778
+ * the session to be thrown away, anything else means they kept it.
17779
+ */
17780
+ /**
17781
+ * Give the keyboard back to the block the editor was opened over.
17782
+ *
17783
+ * v3.3.0's backlog had FOUR accessibility defects and this batch produced two
17784
+ * more, all of them the same sentence: a gesture ended and left
17785
+ * `document.activeElement` on `document.body`, where the user can see
17786
+ * nothing, Tab is deliberately swallowed, and the next Backspace means
17787
+ * whatever the last selection meant. The wave editor is the biggest surface
17788
+ * in the product to close, so it is the worst place to end there.
17789
+ *
17790
+ * The mark it leaves is a single-block SELECTION and not a bare `.focus()`,
17791
+ * because a bare focus is invisible: `.ed-block.ed-selected:focus` has
17792
+ * `outline: none` and the TINT is this editor's affordance for「鍵盤在這裡」
17793
+ * (see that rule's own comment in lib/md2doc.js). It also puts the user back
17794
+ * exactly where the keyboard entry above starts from, so Enter re-opens the
17795
+ * editor they just left.
17796
+ *
17797
+ * Scoped to `seam.reselect` — the sessions that took a keyboard which was
17798
+ * standing somewhere visible. A hover-and-click session is not made worse by
17799
+ * this and is not given a selection it never asked for.
17800
+ */
17801
+ function giveKeyboardBackToWaveBlock(seam) {
17802
+ if (seam === null || seam === undefined || seam.reselect !== true) return;
17803
+ // Resolved by id against the CURRENT map: the commit path has re-rendered
17804
+ // and re-numbered by now, and settleWaveBlocks() has already moved the
17805
+ // session's own line arithmetic into `blocks`. A block that is no longer
17806
+ // there (a render that failed and left the map short) simply gets nothing —
17807
+ // guessing at a neighbour is how a keyboard ends up selecting a block the
17808
+ // user never pointed at.
17809
+ const b = blocks.find((x) => x.id === seam.blockId);
17810
+ if (!b || !blockElById(seam.blockId)) return;
17811
+ setBlockSelection({ anchorLine: b.startLine, focusLine: b.startLine });
17812
+ }
17813
+
17814
+ async function finishWaveSession(seam, reason) {
17815
+ if (seam === null) return;
17816
+ if (reason === 'escape') {
17817
+ // Escape DISCARDS — the same word it means everywhere else in v3.3.0 —
17818
+ // and here that has to reach the document stack, because the session has
17819
+ // been committing all along.
17820
+ const painted = bodyNow(seam);
17821
+ const hadOps = seam.ops > 0;
17822
+ if (!unwindWaveOps(seam)) {
17823
+ // A Ctrl+S landed inside this session, so its commits cannot simply be
17824
+ // popped — see unwindWaveOps() for the measured failure that produces.
17825
+ // A revert COMMIT is the honest answer instead: the document goes back
17826
+ // to where the session found it, and the stack only ever grows, so
17827
+ // `dirtyDepth` stays strictly positive and can never walk back through
17828
+ // zero over a document that differs from disk. The cost is a `●` the
17829
+ // user clears with one Ctrl+S — which is TRUE, because disk is still
17830
+ // holding the drawing they just discarded.
17831
+ const result = commitRangeEdit({ lines, blocks, stack },
17832
+ seam.startLine, seam.endLine, seam.baseSource);
17833
+ if (result.op !== null) lines = result.lines;
17834
+ blocks = seam.baseBlocks;
17835
+ seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
17836
+ // fix 2 (R1): the redo branch, on THIS path too. Both Escape branches
17837
+ // end with `lines` byte-identical to `seam.baseLines` — only the seam
17838
+ // range is ever written during a session — so the pre-session redo ops
17839
+ // still address the document they were recorded against, and a user who
17840
+ // lands here is otherwise in exactly F3's position: a byte-identical
17841
+ // document with their redo silently eaten. AFTER the commit, because
17842
+ // `push()` is what cleared it.
17843
+ stack.setRedoTail(seam.baseRedoTail);
17844
+ }
17845
+ if (hadOps && painted !== seam.baseSource) {
17846
+ // …and the discard is recoverable by exactly one Ctrl+Z. See
17847
+ // discardedWaveEdit's own comment for which of discardedBurst's rules
17848
+ // this reuses and which of them cannot apply here.
17849
+ dropDiscardedBurst();
17850
+ dropDiscardedRawEdit();
17851
+ discardedWaveEdit = {
17852
+ blockId: seam.blockId,
17853
+ blockStartLine: seam.blockStartLine, blockEndLine: seam.blockEndLine,
17854
+ startLine: seam.startLine, endLine: seam.endLine,
17855
+ source: seam.baseSource, painted: painted,
17856
+ };
17857
+ }
17858
+ // Work the file could not hold goes with the session it belonged to.
17859
+ // The banner that said so is deliberately NOT dismissed here: it is the
17860
+ // only record the user has that something did not survive.
17861
+ waveUnwrittenEdit = false;
17862
+ setDirty();
17863
+ // No render. `.content` was never repainted while the overlay was up, so
17864
+ // it is still showing exactly the base document just restored — rendering
17865
+ // would be a round trip to say nothing.
17866
+ return;
17867
+ }
17868
+ if (seam.unwritten) {
17869
+ // Kept, with a hole in it. The user is told once more on the way out,
17870
+ // because the status line they were reading has just been removed from
17871
+ // the screen along with the rest of the dialog.
17872
+ showBanner(
17873
+ '這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
17874
+ '寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
17875
+ seam.endLine + ' 行)。',
17876
+ null, null);
17877
+ waveUnwrittenEdit = false;
17878
+ setDirty();
17879
+ }
17880
+ if (seam.ops === 0) return;
17881
+ // fix 1 (F2): `blocks` is settled from the session's own arithmetic BEFORE
17882
+ // the render, not instead of it. A successful render overwrites it with the
17883
+ // server's own map a moment later; a FAILED one — which this path
17884
+ // deliberately does not roll back — leaves this value standing instead of a
17885
+ // map addressing a document that no longer exists. It also closes the
17886
+ // window in which the overlay is reopened while the settling render is
17887
+ // still in flight, which reads `blocks` through waveBodyOf().
17888
+ settleWaveBlocks(seam);
17889
+ // The one render this session pays for. `.content` has been lagging
17890
+ // `lines` since the first gesture; this is where it catches up.
17891
+ // fix 1 (F7): the delta is the session's real line-count change, not 0.
17892
+ // Advisory today (patchmap() reads only its truthiness), wrong regardless.
17893
+ const editRange = { startLine: seam.startLine, endLine: seam.endLine,
17894
+ delta: lines.length - seam.baseLines.length };
17895
+ const ok = await safeRerenderAll(editRange);
17896
+ if (!ok) {
17897
+ // Deliberately NOT rollbackFailedRender(). Every other commit in this
17898
+ // file rolls back a failed render because the edit it is undoing is one
17899
+ // keystroke; this one can be ten minutes of drawing, and rerenderAll()'s
17900
+ // failure is a round trip to /api/render, not a refusal of the edit. The
17901
+ // work stays in `lines` — dirty, saveable with Ctrl+S, and recoverable by
17902
+ // an ordinary Ctrl+Z — and the banner is corrected, because
17903
+ // rerenderAll()'s own 'Your edit was not applied' would be false here.
17904
+ //
17905
+ // What makes that safe is settleWaveBlocks() above: keeping the work is
17906
+ // only defensible while `blocks` still addresses the document that holds
17907
+ // it. Losing a session is bad; silently corrupting the document the user
17908
+ // keeps editing afterwards is worse, and before fix 1 this path risked
17909
+ // both at once.
17910
+ showBanner(
17911
+ '畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
17912
+ 'Ctrl+Z 可以撤銷)。', null, null);
17913
+ }
17914
+ }
17915
+
17916
+ /**
17917
+ * Ctrl+Z straight after an Escape out of the wave editor: put the discarded
17918
+ * drawing back and re-open the editor over it.
17919
+ *
17920
+ * The restore is an ordinary commit, not a special case — so the reopened
17921
+ * session opens over a document that already holds the drawing, one more
17922
+ * Ctrl+Z steps back over the restore itself, and Ctrl+S saves it. That is
17923
+ * the difference from restoreDiscardedRawEdit(), whose stashed value lives
17924
+ * in a textarea until a later blur commits it: there is no "uncommitted
17925
+ * surface" shape available here, because the wave editor's surface IS the
17926
+ * document.
17927
+ *
17928
+ * Staleness is asked at the `lines`/`blocks` level, the same level
17929
+ * restoreDiscardedRawEdit() asks at and for the same reason — the overlay
17930
+ * was removed from the DOM by `close()`, so there is no node whose presence
17931
+ * could answer it.
17932
+ */
17933
+ async function restoreDiscardedWaveEdit() {
17934
+ const d = discardedWaveEdit;
17935
+ discardedWaveEdit = null;
17936
+ if (!d) return false;
17937
+ const b = blocks.find((x) => x.id === d.blockId);
17938
+ if (!b || b.startLine !== d.blockStartLine || b.endLine !== d.blockEndLine) return false;
17939
+ if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
17940
+ const prevLines = lines;
17941
+ const result = commitRangeEdit({ lines, blocks, stack },
17942
+ d.startLine, d.endLine, d.painted);
17943
+ if (result.op === null) return false;
17944
+ lines = result.lines;
17945
+ // `blocks` deliberately not reassigned — the same shape every other
17946
+ // commit-then-render site in this file has: the render that follows is what
17947
+ // replaces it, and rollbackFailedRender() below restores `lines` only, so
17948
+ // an assignment here would survive a failed render as the one value nothing
17949
+ // puts back.
17950
+ setDirty();
17951
+ const ok = await safeRerenderAll({
17952
+ startLine: d.startLine,
17953
+ endLine: d.startLine + d.painted.split('\n').length - 1,
17954
+ delta: result.op.after.length - result.op.before.length,
17955
+ });
17956
+ if (!ok) {
17957
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
17958
+ setDirty();
17959
+ return false;
17960
+ }
17961
+ // Re-open over the block the render just rebuilt. `openWaveEditor()` wants
17962
+ // something INSIDE the block and resolves the block with `closest()`, and
17963
+ // `closest()` matches the element itself — so the block element is a valid
17964
+ // argument and does not depend on WaveDrom having finished painting a
17965
+ // `.wavedrom-diagram` into it yet.
17966
+ const blockEl = blockElById(d.blockId);
17967
+ if (!blockEl) return true; // the drawing is back in the document either way
17968
+ await openWaveEditor(blockEl);
17969
+ return true;
17970
+ }
17971
+
17972
+ // Test-only seam, the same shape and for the same reason as
17973
+ // `__edTestGetSelection` above: the two invariants this feature has that a
17974
+ // scenario cannot otherwise see — whether the overlay is open, and whether
17975
+ // anything ever reached the seam after it closed. Nothing in the product
17976
+ // calls it.
17977
+ window.__edTestWaveState = function () {
17978
+ return {
17979
+ open: waveEditor !== null,
17980
+ stray: waveStrayGestures,
17981
+ // Task 7's three: whether the document currently believes it has
17982
+ // anything unsaved (the thing the Reload button's beforeunload guard
17983
+ // asks), whether this session has work the file could not hold, and
17984
+ // whether a discarded session is sitting there waiting for a Ctrl+Z.
17985
+ dirty: documentIsDirty(),
17986
+ unwritten: waveUnwrittenEdit,
17987
+ stashed: discardedWaveEdit !== null,
17988
+ seam: waveSeam === null ? null : {
17989
+ blockId: waveSeam.blockId,
17990
+ startLine: waveSeam.startLine,
17991
+ endLine: waveSeam.endLine,
17992
+ gestured: waveSeam.lastGesture !== null,
17993
+ ops: waveSeam.ops,
17994
+ },
17995
+ };
17996
+ };
17997
+
17998
+ // fix 1 (F2): whether the block map still addresses the document. A failed
17999
+ // close render used to leave the two disagreeing by the session's line delta,
18000
+ // and the only way a scenario could see it was to make an ordinary edit
18001
+ // afterwards and read the corrupted file — a real assertion, but one that
18002
+ // cannot say WHICH of the two moved. Nothing in the product calls this.
18003
+ window.__edTestBlockSpan = function (id) {
18004
+ const b = blocks.find((x) => x.id === id);
18005
+ return b === undefined ? null : {
18006
+ startLine: b.startLine, endLine: b.endLine, docLines: lines.length,
18007
+ text: lines.slice(b.startLine - 1, b.endLine).join('\n'),
18008
+ };
18009
+ };
18010
+
18011
+ waveEditBtn.addEventListener('click', () => {
18012
+ if (waveDiagramEl === null) return;
18013
+ // The only way this rejects is a programming error — the `.content` mount
18014
+ // guard, which throws by design, or a store that threw where it promised
18015
+ // not to. Reported rather than swallowed, and NOT rethrown: an unhandled
18016
+ // rejection out of a click handler is a page-level error, and the state
18017
+ // this needs to keep consistent (`waveEditor` / `waveSeam`) is already
18018
+ // unwound above.
18019
+ openWaveEditor(waveDiagramEl).catch((err) => {
18020
+ console.error('md2doc: the wave editor could not open', err);
18021
+ });
18022
+ });
18023
+
14952
18024
  // v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
14953
18025
  // selToolbar, …) is initialised and the bar's first deriveState() sees the
14954
18026
  // armed document rather than an empty one.