@helping-ai-workflow/md2doc 3.3.0 → 3.4.2

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.
@@ -145,7 +145,63 @@
145
145
  // in the opposite direction (a zero-width "after" range) to let the
146
146
  // existing UndoStack undo()/redo() pair (lib/editor/lineops.js) handle a
147
147
  // pure insertion/pure removal without a third op shape.
148
- function commitBlockInsertion(state, blockId, newBlockLines) {
148
+ /**
149
+ * Is every line of this paste part of a list?
150
+ *
151
+ * "Part of a list" is a list line OR an indented continuation of one — a
152
+ * wrapped sentence, a nested block, a fenced snippet inside an item. Blank
153
+ * lines inside the paste are allowed (a list may be loose on purpose; what
154
+ * this decides is only whether to ADD one at the seam).
155
+ *
156
+ * A paste that is a list FOLLOWED BY a paragraph answers false, and should:
157
+ * the blank line before that paragraph is what keeps it out of the last
158
+ * item, and the seam's own blank is the lesser problem of the two.
159
+ */
160
+ function pasteIsAllListLines(pastedLines) {
161
+ let sawListLine = false;
162
+ for (const raw of pastedLines) {
163
+ const line = String(raw == null ? '' : raw);
164
+ if (line.trim() === '') continue;
165
+ if (LIST_LINE_RE.test(line)) { sawListLine = true; continue; }
166
+ // A continuation has to be indented AND it has to belong to something.
167
+ if (sawListLine && /^[ \t]/.test(line)) continue;
168
+ return false;
169
+ }
170
+ return sawListLine;
171
+ }
172
+
173
+ // Does this line open a list item? Deliberately the same shape as
174
+ // list-md.js's own SRC_MARKER_RE (`^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)…`),
175
+ // which is function-local there and answers a different question — it
176
+ // PARSES a marker, this only CLASSIFIES a line. The two are kept in
177
+ // agreement by a test that runs both over the same table of lines rather
178
+ // than by sharing one object: making that one exportable would drag a
179
+ // browser-side module into commitBlockInsertion(), whose whole value is
180
+ // that it is a pure function node can call directly.
181
+ const LIST_LINE_RE = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/;
182
+
183
+ /**
184
+ * `opts.tight` — insert with NO leading blank line, and skip the trailing
185
+ * one when what follows is itself a list line.
186
+ *
187
+ * The always-on blank is right for a paragraph and wrong for a list item:
188
+ * a blank line between items makes the list LOOSE, every item then renders
189
+ * as <p>, serializeBlocks() answers 'P' per item, and the run degrades
190
+ * READ-ONLY with no banner. A user meets that as "I pasted an item beside
191
+ * my list and now Tab does nothing" — no error, no explanation, nothing to
192
+ * search for.
193
+ *
194
+ * It is why insertListItemAfter() and duplicateListItems() bypass this
195
+ * function. Paste could not: it carries arbitrary pasted lines, and
196
+ * re-serialising a run to absorb them would REWRITE the user's bytes —
197
+ * marker widths, ordinals, and the escaping trap duplicateListItems()
198
+ * records ('~5px' coming back as '\~5px'). So paste keeps the raw-line
199
+ * insert and turns the leading blank off instead.
200
+ *
201
+ * Opt-in, decided by the caller: every other call site wants the blank.
202
+ */
203
+ function commitBlockInsertion(state, blockId, newBlockLines, opts) {
204
+ const tight = !!(opts && opts.tight);
149
205
  const block = state.blocks.find((b) => b.id === blockId);
150
206
  // T8 item 1, third helper in the same family: the anchor's range is read as
151
207
  // an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
@@ -157,20 +213,26 @@
157
213
  if (anchorRefusal) return anchorRefusal;
158
214
  const endLine = block.endLine;
159
215
  const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
160
- const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '';
216
+ // A list line following a tight insert needs no separator: emitting one
217
+ // would divorce THAT item from the run instead — the same defect, one
218
+ // item further down.
219
+ const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '' &&
220
+ !(tight && LIST_LINE_RE.test(nextLine));
221
+ const lead = tight ? [] : [''];
161
222
  const after = needsTrailingBlank
162
- ? ['', ...newBlockLines, '']
163
- : ['', ...newBlockLines];
223
+ ? [...lead, ...newBlockLines, '']
224
+ : [...lead, ...newBlockLines];
164
225
  const op = { startLine: endLine + 1, endLine, before: [], after };
165
226
  const r = ops.insertLines(state.lines, endLine, after);
166
227
  const blocks = ops.shiftBlocks(state.blocks, blockId, r.delta);
167
228
  state.stack.push(op);
168
229
  // The new block's own content starts one line after the leading blank
169
- // this function always inserts (see `after` above — its first element
170
- // is always the fresh leading blank) — callers use this to locate the
171
- // freshly-inserted block in the blocks array a subsequent full
172
- // rerenderAll() (which recomputes blocks server-side) hands back.
173
- const newStartLine = op.startLine + 1;
230
+ // this function inserts by default (see `after` above) — callers use this
231
+ // to locate the freshly-inserted block in the blocks array a subsequent
232
+ // full rerenderAll() (which recomputes blocks server-side) hands back.
233
+ // A tight insert emits no leading blank, so its content begins on the
234
+ // first inserted line itself.
235
+ const newStartLine = tight ? op.startLine : op.startLine + 1;
174
236
  return { lines: r.lines, blocks, op, newStartLine };
175
237
  }
176
238
 
@@ -506,8 +568,147 @@
506
568
  return rest === '' ? '#'.repeat(newDepth) : '#'.repeat(newDepth) + ' ' + rest;
507
569
  }
508
570
 
571
+ // v3.4.0 batch2 Task 6 fix round 1 (review F6) — which page of a re-baked
572
+ // multi-page drawio box the reader should land on, and whether we are
573
+ // entitled to tell them their page is gone.
574
+ //
575
+ // Page names are NOT identities. lib/drawio.js's pageNamesOf() pushes '' for
576
+ // any <diagram> with no name attribute (normal in hand-written and
577
+ // tool-exported files) and nothing de-duplicates the list, so the old
578
+ // `newNames.indexOf(keepName)` had two opposite failure modes on perfectly
579
+ // ordinary input: an all-unnamed file always resolved to index 0, silently
580
+ // moving the reader back to page 1 with no banner; and a `keepName` of null
581
+ // (nothing identifiable was open) resolved to -1, raising a banner claiming
582
+ // a deletion that never happened. A false banner is worse than no banner —
583
+ // it is what teaches a user to ignore the true one.
584
+ //
585
+ // So: use the name only where the name IS an identity (non-empty AND unique
586
+ // in the old list), fall back to position otherwise, and claim deletion only
587
+ // where it is actually established — a once-unique name that is now absent,
588
+ // or a position the document no longer reaches.
589
+ //
590
+ // `gone: false` with a moved `landOn` is not silence: the caller lands the
591
+ // reader on a page that is genuinely the same one (renumbered by a reorder),
592
+ // which is the outcome Ruling B2-P1's "page still there" branch describes.
593
+ function resolveDrawioLanding(oldNames, activeIdx, newNames) {
594
+ const olds = Array.isArray(oldNames) ? oldNames : [];
595
+ const news = Array.isArray(newNames) ? newNames : [];
596
+ if (news.length === 0) return { landOn: 0, gone: false };
597
+ // Nothing identifiable was open (no visible .drawio-page, or an index the
598
+ // old name list never had). We know nothing, so we claim nothing.
599
+ if (!(activeIdx >= 0) || activeIdx >= olds.length) return { landOn: 0, gone: false };
600
+ const name = olds[activeIdx];
601
+ let oldCount = 0;
602
+ for (let i = 0; i < olds.length; i++) if (olds[i] === name) oldCount++;
603
+ if (name !== '' && oldCount === 1) {
604
+ const hits = [];
605
+ for (let i = 0; i < news.length; i++) if (news[i] === name) hits.push(i);
606
+ // Exactly one match: a rename-free reorder, resolved exactly.
607
+ // More than one: the name stopped being an identity between the two
608
+ // bakes — land on the first bearer, but do NOT claim a deletion we
609
+ // cannot establish.
610
+ if (hits.length > 0) return { landOn: hits[0], gone: false };
611
+ return { landOn: 0, gone: true };
612
+ }
613
+ // Empty or duplicated name: position is the only sound identity left.
614
+ if (activeIdx < news.length) return { landOn: activeIdx, gone: false };
615
+ return { landOn: 0, gone: true };
616
+ }
617
+
618
+ // v3.4.0 batch3 Task 7 fix 1 (F2) — the block map after an edit that changed
619
+ // the line count INSIDE one block and nowhere else.
620
+ //
621
+ // This is not `ops.shiftBlocks()` on its own, and the difference is the whole
622
+ // point. `commitRangeEdit()` anchors its shift on "the last block ENDING at
623
+ // or before the edited range", and for an edit to a fenced block's BODY that
624
+ // anchor is the block BEFORE the fence — so its answer moves the fenced
625
+ // block's own `startLine`, which did not move, and leaves its `endLine`
626
+ // where it was, which did. Here the edited block is named outright: it keeps
627
+ // its `startLine`, its `endLine` absorbs the delta, every block after it
628
+ // moves by the delta, and every block before it is untouched.
629
+ //
630
+ // Why it exists at all: the wave editor defers the block map to the render at
631
+ // the end of a session, and a render can FAIL. Before this, a failed close
632
+ // render left `blocks` addressing the pre-session document while `lines`
633
+ // carried the session's delta — MEASURED, the next ordinary paragraph edit
634
+ // then committed over the blank line after the closing fence, eating the
635
+ // separator and duplicating the paragraph, with no warning of any kind. The
636
+ // rule this restores is that no ordinary edit may ever land against a block
637
+ // map the renderer has not confirmed: where the renderer cannot confirm it,
638
+ // the session's own arithmetic settles it instead.
639
+ function shiftBlocksAfterBodyEdit(blocks, blockId, delta) {
640
+ if (delta === 0) return blocks;
641
+ return blocks.map((b) => {
642
+ if (b.id === blockId) return Object.assign({}, b, { endLine: b.endLine + delta });
643
+ if (b.id > blockId) {
644
+ return Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta });
645
+ }
646
+ return b;
647
+ });
648
+ }
649
+
650
+ // v3.4.0 batch3 Task 7 fix 2 (MUST-FIX 1) — may a wave session's own commits
651
+ // simply be popped off the document stack?
652
+ //
653
+ // Two questions, asked separately, each answered by the stack itself rather
654
+ // than by arithmetic over a quantity that can be wrong:
655
+ //
656
+ // `depth` are there still exactly `ops` entries above where this
657
+ // session started? The pop removes that many, so anything
658
+ // else pushing inside the session (see openWaveEditor()'s
659
+ // `switchAwayFrom()` guard) would make it reverse somebody
660
+ // else's edit.
661
+ // ⚠ This proves the COUNT, not the identity. A foreign push
662
+ // paired with a foreign pop inside one session satisfies it —
663
+ // `discardPristineInsert()` is exactly that shape. It is not
664
+ // reachable today for one reason and it is worth naming: the
665
+ // modal returns on every non-save key, and the only path that
666
+ // could open a block editor mid-session is Ctrl+S's
667
+ // `switchAwayFrom()`, which openWaveEditor() now resolves
668
+ // BEFORE the overlay opens. If that guard ever moves, this
669
+ // check has to become identity-based.
670
+ // `savedDepth` would the pop destroy the state disk was written from? It
671
+ // takes the stack from `baseDepth + ops` back to `baseDepth`,
672
+ // so the answer is yes exactly when the save marker sits
673
+ // ABOVE `baseDepth` — i.e. names a state only reachable
674
+ // through this session's own ops. `null` (the marker
675
+ // invalidated because history diverged past it) is safe: it
676
+ // names nothing, and the document reads dirty either way.
677
+ // ⚠ fix 4 (R4): this question is answered with the marker as
678
+ // it stands NOW, and a save that is still in flight has not
679
+ // moved it yet. MEASURED: paint twice, Ctrl+S inside the
680
+ // session, Escape before the reply — this says the pop is
681
+ // safe and pops a drawing the server has already written. It
682
+ // is not a hole, because the receipt closes it from the other
683
+ // end: when that reply lands, `markSaved()` finds the
684
+ // timeline at its boundary gone, invalidates the marker, and
685
+ // the document reads dirty. But this function cannot see it,
686
+ // and the honest reading of its answer is "safe as far as any
687
+ // save that has REPLIED is concerned".
688
+ //
689
+ // Both are POSITIONS compared against the session's own baseline, which is
690
+ // what makes them sign-safe. What they REPLACE: fix 1 derived "no save
691
+ // happened" from `stack.dirtyDepth === baseDirtyDepth + ops`. That
692
+ // implication only runs the way it was used when `baseDirtyDepth >= 0`;
693
+ // algebraically it is equally satisfied when
694
+ // `baseDirtyDepth === -(ops taken before the save)`, and a user reaches a
695
+ // negative baseline by pressing Ctrl+Z once after a save. MEASURED: edit,
696
+ // Ctrl+S, Ctrl+Z, open the editor, paint, Ctrl+S, paint, Escape — the
697
+ // predicate read `1 === -1 + 2`, answered "no save", and popped blind,
698
+ // reproducing the original defect through the fixed code.
699
+ //
700
+ // Deliberately NOT "did the marker move": the marker legitimately goes
701
+ // 1 → null → 1 inside one session (the first wave commit invalidates it, a
702
+ // mid-session Ctrl+S establishes it again), so watching it for change both
703
+ // misses that case and refuses the harmless one where only the invalidation
704
+ // happened.
705
+ function waveDiscardIsSafe(stack, seam) {
706
+ if (stack.depth !== seam.baseDepth + seam.ops) return false;
707
+ return stack.savedDepth === null || stack.savedDepth <= seam.baseDepth;
708
+ }
709
+
509
710
  if (typeof module === 'object' && module.exports) {
510
- module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
711
+ module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
511
712
  return; // node: pure core only
512
713
  }
513
714
 
@@ -532,6 +733,20 @@
532
733
  const docSourceLib = window.md2docDocSource;
533
734
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
534
735
  let lastParts = null;
736
+ // v3.4.0 batch2 Task 6 fix round 1 (review F1) — true while `lastParts` is
737
+ // the bootstrap seed taken off the server-rendered DOM (see just below
738
+ // contentEl) rather than a `parts` array a render actually handed back.
739
+ // The seed is byte-for-byte the DOM's OWN serialization, which is not the
740
+ // same string the server emitted (the HTML parser normalizes attribute
741
+ // quoting, `hidden` becomes `hidden=""`, and so on), so it is fit for the
742
+ // positional/id checks domMatchesLastRender() makes and for "did this
743
+ // block's baked output change at all", but NOT for patchmap(), which
744
+ // diffs the strings themselves. It is also what keeps the documented
745
+ // "the first commit after a page load is ALWAYS a full render" behaviour
746
+ // exactly as it was — test/editor-journey.test.js's primed/unprimed axis
747
+ // is built on that property and would silently lose half its coverage if
748
+ // seeding `lastParts` quietly enabled the patch route one commit earlier.
749
+ let lastPartsIsBootstrapSeed = false;
535
750
  // F10: 上一次【成功套用】的 render 留下來的東西 —— 當時的整份 `lines`,以及
536
751
  // 當時的文件是不是以一道沒閉合的圍欄收尾。
537
752
  //
@@ -552,6 +767,28 @@
552
767
  const stack = new ops.UndoStack();
553
768
  const baseTitle = document.title;
554
769
  const contentEl = document.querySelector('.content');
770
+ // v3.4.0 batch2 Task 6 fix round 1 (review F1, BLOCKING) — establish, at
771
+ // first paint, the knowledge domMatchesLastRender() is a check ON.
772
+ //
773
+ // `lastParts` used to stay null until this tab's FIRST COMMIT, because the
774
+ // bootstrap payload carries no `parts` and the initial DOM is rendered
775
+ // server-side (no client render runs at load). refreshStaleDrawio()'s
776
+ // "does the DOM still match the render I last knew about" guard is
777
+ // therefore unconditionally false on a freshly opened tab — and "open a
778
+ // document, don't edit it, change the .drawio in Draw.io" is the exact
779
+ // story this whole feature exists for. The re-bake was computed, paid for
780
+ // (a headless-Chromium bake) and thrown away, while the server's baseline
781
+ // advanced so the signal never came again.
782
+ //
783
+ // The guard is right; the bootstrap simply never established what it
784
+ // checks. It does now: at this point .content's children ARE the render
785
+ // the server just sent, one element per block, so reading them back is not
786
+ // an approximation of that knowledge — it is that knowledge. Taken here,
787
+ // before armEditables() (called at the very bottom of this IIFE) adds its
788
+ // own attributes, so the seed describes the render rather than the armed
789
+ // document.
790
+ lastParts = Array.prototype.map.call(contentEl.children, (el) => el.outerHTML);
791
+ lastPartsIsBootstrapSeed = true;
555
792
 
556
793
  // Click-to-switch substrate (Phase-2 Task 1; replaces the old "refuse a
557
794
  // second block's editor outright" policy). At most one block editor is
@@ -620,6 +857,90 @@
620
857
  discardedBurst = null;
621
858
  }
622
859
 
860
+ // v3.4.0 (Task 7 item 5) — the raw-editor twin of `discardedBurst` just
861
+ // above. Same one-shot contract (answers exactly one Ctrl+Z, the one right
862
+ // after the Escape/✕ that created it) and the SAME invalidation rule
863
+ // (dropped the moment any new burst/raw session opens, or the page
864
+ // re-renders) — see discardedBurst's own comment for why that window has
865
+ // to be this narrow. It cannot REUSE discardedBurst's mechanism, though:
866
+ // that one parks a live `history` object keyed on the burst's own DOM node,
867
+ // and openRawEditor() tears its OWN surface down on open
868
+ // (`blockEl.innerHTML = ''`) before a textarea ever exists — there is no
869
+ // node to park a history against. So this parks a plain VALUE snapshot of
870
+ // the textarea instead (what was typed, where the caret was, and enough of
871
+ // the session's own addressing — blockId/range/startLine/endLine/source —
872
+ // to re-derive the same block and verify nothing else moved underneath it)
873
+ // and Ctrl+Z's job is to RE-OPEN the raw editor and put that value back —
874
+ // not to commit it. Escape's semantics are unchanged: it still discards,
875
+ // still leaves. What changes is that the discard is no longer permanent.
876
+ //
877
+ // `discardedBurst` and this are mutually exclusive by construction (only
878
+ // one editing session — a WYSIWYG burst or a raw editor — is ever open at
879
+ // once): cancelAndMaybeDiscard() below drops any live `discardedBurst` the
880
+ // moment IT stashes here, and revertBurstAndEnd()/stashDiscardedTableBurst()
881
+ // drop this the moment THEY stash into `discardedBurst` — so undo()'s
882
+ // "check the burst stash, then the raw stash" order never answers a stale
883
+ // one while a fresher discard from the OTHER substrate is sitting unused.
884
+ let discardedRawEdit = null; // { blockId, range, startLine, endLine, source, value, caret } | null
885
+
886
+ function dropDiscardedRawEdit() {
887
+ discardedRawEdit = null;
888
+ }
889
+
890
+ // v3.4.0 batch3 Task 7 — the wave-editor member of the same family as the
891
+ // two stashes above. Escape out of the wave overlay still DISCARDS, and this
892
+ // is what makes the discard recoverable by exactly one document-level
893
+ // Ctrl+Z, the same one-shot contract `discardedBurst` spells out.
894
+ //
895
+ // Which of discardedBurst's rules are reused here, and which cannot be:
896
+ //
897
+ // REUSED — the one-shot window (the only Ctrl+Z it answers is the one
898
+ // right after the Escape that created it), the drop-on-any-render rule
899
+ // (rerenderAll() clears all three at its top), mutual exclusivity with the
900
+ // other two, and a staleness guard that refuses rather than applying a
901
+ // restore over a document that moved underneath it.
902
+ //
903
+ // NOT APPLICABLE — parking a live `history` object keyed on the burst's
904
+ // own DOM node. discardedBurst can do that because the burst's
905
+ // contenteditable survives its own Escape; the wave overlay is mounted on
906
+ // `document.body` and `close()` REMOVES it (and every document listener it
907
+ // installed) before onClose ever runs, so there is no node and no live
908
+ // store left to park. Nor is `editEl`/`burstBaselineHtml()` meaningful:
909
+ // nothing the wave editor produces was ever HTML in `.content`.
910
+ //
911
+ // NOT APPLICABLE — "the burst never committed, so `lines` never saw it".
912
+ // The wave editor writes back PER GESTURE, so by the time Escape is
913
+ // pressed `lines` has seen every one of them and the document stack is
914
+ // carrying them. That inverts what Escape has to do: it is the one stash
915
+ // in this file whose creation REMOVES ops from the stack (see
916
+ // finishWaveSession()) rather than never having put any there.
917
+ //
918
+ // So this parks a plain VALUE pair — the block body as it stood before the
919
+ // session (`source`, which is what `lines` holds again once the Escape has
920
+ // run) and as it stood at the moment of the Escape (`painted`) — plus enough
921
+ // addressing to prove nothing else moved. Ctrl+Z's job is to put `painted`
922
+ // back through an ordinary commit and RE-OPEN the editor over it.
923
+ let discardedWaveEdit = null; // { blockId, blockStartLine, blockEndLine, startLine, endLine, source, painted } | null
924
+
925
+ function dropDiscardedWaveEdit() {
926
+ discardedWaveEdit = null;
927
+ }
928
+
929
+ // v3.4.0 batch3 Task 7 — a wave gesture whose patch was REFUSED, i.e. work
930
+ // the user can see in the overlay that `lines` does not hold and cannot be
931
+ // made to hold by a local patch. Declared up here beside the stashes rather
932
+ // than down with the rest of the wave wiring (~16900) for one reason:
933
+ // documentIsDirty() reads it, documentIsDirty() is reachable from the very
934
+ // first setDirty(), and a `let` declared further down would still be in its
935
+ // temporal dead zone then.
936
+ //
937
+ // This is the second half of the Reload gate. The first half is free —
938
+ // every write-back that SUCCEEDS goes through commitRangeEdit(), so
939
+ // `stack.dirtyDepth` already counts it and the beforeunload guard already
940
+ // covers it. A refusal is the case that guard cannot see, because by
941
+ // definition nothing reached `lines`.
942
+ let waveUnwrittenEdit = false;
943
+
623
944
  // §10-gap fix (review): the block insertBlockBelow() most recently
624
945
  // inserted, tracked from the moment its edit surface is first focused
625
946
  // until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
@@ -772,9 +1093,13 @@
772
1093
  return await activeEditor.commitNow(); // false → editor stays open, banner already shown
773
1094
  }
774
1095
 
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
1096
+ // `stack.isDirty()` asks whether the undo stack is standing on the state the
1097
+ // last save wrote (lineops.js). `dirtyDepth` is the same question as a
1098
+ // distance, and only while that distance exists at all: once a push has
1099
+ // diverged past the save marker the marker is invalidated, and the getter
1100
+ // then answers `_done.length + 1` — never 0. Either way the answer moves
1101
+ // when an op is pushed onto or popped off the stack, or when a save
1102
+ // re-baselines it. The burst substrate keeps what the user typed in
778
1103
  // the DOM until the burst resolves, and none of those has happened yet at
779
1104
  // that point — so a block that has been typed into but not yet left reads
780
1105
  // as clean. MEASURED on `# Doc\n\nAlpha paragraph.\n` (click the
@@ -791,9 +1116,35 @@
791
1116
  burstBaselineHtml(currentBurst.editEl) !== currentBurst.original);
792
1117
  }
793
1118
 
1119
+ // v3.4.0 §3: single source of truth for "does the document have anything
1120
+ // unsaved" — setDirty() (the title dot), toolbarContext() (the `save`
1121
+ // button's own lit/grey state), and the beforeunload guard further below
1122
+ // all ask this instead of each carrying its own copy of the expression
1123
+ // (review I1: the beforeunload handler used to be a second copy — found
1124
+ // and fixed). Do not inline this back into any call site; that is exactly
1125
+ // the drift this function exists to prevent.
1126
+ // v3.4.0 batch3 Task 7 adds the third term. MEASURED before it existed, with
1127
+ // the wave editor open over a real paint: `document.title` was 'doc', the
1128
+ // file on disk was byte-identical, and pressing the conflict banner's Reload
1129
+ // raised NO beforeunload dialog at all — because this predicate is about
1130
+ // `lines`, and a waveform had never been in `lines`. Two thirds of that gap
1131
+ // close by themselves the moment write-back goes through commitRangeEdit()
1132
+ // (`stack.dirtyDepth` counts it like any other commit); `waveUnwrittenEdit`
1133
+ // is the remaining third — a gesture the store could not express as a local
1134
+ // patch, which the user can see on screen and `lines` does not hold.
1135
+ function documentIsDirty() {
1136
+ // fix 2: `stack.isDirty()`, not `stack.dirtyDepth !== 0`. MEASURED on the
1137
+ // shipped behaviour, with no wave editor involved: edit, Ctrl+S, Ctrl+Z,
1138
+ // one more ordinary edit — the depth came back to 0 FROM BELOW and this
1139
+ // answered false over a document that differed from disk in both edits.
1140
+ // The distance was a second record of "are we standing where we saved";
1141
+ // `UndoStack` now answers that question itself, and a boolean has no zero
1142
+ // to walk through. See lineops.js's own comment for the full story.
1143
+ return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit;
1144
+ }
1145
+
794
1146
  function setDirty() {
795
- const dirty = stack.dirtyDepth !== 0 || burstHasUncommittedEdit();
796
- document.title = (dirty ? '● ' : '') + baseTitle;
1147
+ document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
797
1148
  }
798
1149
 
799
1150
  // setDirty() is push-driven: something has to call it. MEASURED by keeping
@@ -865,6 +1216,12 @@
865
1216
  // refusal 一樣需要一個「後來的事件可以把它收掉」的記號,但收掉它的條件不
866
1217
  // 同(見 dismissSwallowBanner()),所以是另一個旗標而不是共用那一個。
867
1218
  let activeBannerIsSwallow = false;
1219
+ // Task 9 review I3: the same shape as activeBannerIsRefusal/
1220
+ // activeBannerIsSwallow above, for the keynav-exit banner backlog #10
1221
+ // added. It needs its own dismiss condition too — see
1222
+ // dismissKeynavExitBanner()'s own comment for why Escape, specifically,
1223
+ // is the only OTHER thing allowed to clear it.
1224
+ let activeBannerIsKeynavExit = false;
868
1225
  function showBanner(message, actionLabel, onAction) {
869
1226
  if (activeBanner) { activeBanner.remove(); activeBanner = null; }
870
1227
  // Reset unconditionally: refuseStructuralListEdit() sets it back to true
@@ -872,6 +1229,39 @@
872
1229
  // having to know this flag exists.
873
1230
  activeBannerIsRefusal = false;
874
1231
  activeBannerIsSwallow = false;
1232
+ activeBannerIsKeynavExit = false;
1233
+ // backlog #8 fix: `.ed-conflict` now sits at `top: var(--ed-toolbar-h)`
1234
+ // (lib/md2doc.js) — the SAME floor `.ed-te-menu` (placeTeMenuAboveCell())
1235
+ // explicitly clamps to when there is no room above (`if (top < topInset)
1236
+ // top = r.bottom + …`, `topInset` read straight from --ed-toolbar-h).
1237
+ // Final-review I1 correction: an earlier draft of this comment also named
1238
+ // `.ed-toolbar-menu` here (`toggleToolbarHeadingMenu(): top = r.bottom +
1239
+ // 4`) as if that `+ 4` were the same floor. Retracted — MEASURED:
1240
+ // toggleToolbarHeadingMenu() never reads --ed-toolbar-h at all; its `r` is
1241
+ // the heading-dropdown BUTTON's own rect (a toolbar button, so already
1242
+ // near the band), not a clamp against the token. It lands near the same
1243
+ // band as a side effect of where its trigger sits, not because of a
1244
+ // shared floor. (positionSelToolbar()'s `.ed-seltb` is the same story,
1245
+ // for the same reason — see the parallel correction on the `.ed-conflict`
1246
+ // CSS comment in lib/md2doc.js.)
1247
+ // Any of the four dynamic fixed overlays below could nonetheless be
1248
+ // showing in exactly the band the banner is about to claim; without this,
1249
+ // the banner (z-index 999, above all four) would paint over whichever one
1250
+ // is open, reproducing backlog #8's own defect one layer down instead of
1251
+ // fixing it. Same four functions onAnyScroll() already calls for an
1252
+ // analogous reason (something big just happened; these four don't
1253
+ // outlive it) — reused rather than re-invented — but NOT the identical
1254
+ // call: onAnyScroll() guards its hideTableEdgeMenu() with
1255
+ // `if (!suppressEdgeMenuAutoHide)`, this call is unconditional. The one
1256
+ // window that guard exists for is runCycleAlign()'s ensureTableBurstOpen()
1257
+ // span, which can itself raise a banner (a structural refusal) — but that
1258
+ // branch bails immediately and resets the flag to false before doing
1259
+ // anything the menu's reposition-in-progress state could be corrupted
1260
+ // by, so an unconditional close here has no real target to clobber.
1261
+ hideTableGrips();
1262
+ hideTableInsertBubbles();
1263
+ hideTableEdgeMenu();
1264
+ closeToolbarMenu();
875
1265
  const el = document.createElement('div');
876
1266
  el.className = 'ed-conflict';
877
1267
  const msg = document.createElement('span');
@@ -894,6 +1284,7 @@
894
1284
  activeBanner = null;
895
1285
  activeBannerIsRefusal = false;
896
1286
  activeBannerIsSwallow = false;
1287
+ activeBannerIsKeynavExit = false;
897
1288
  }
898
1289
  });
899
1290
  el.appendChild(dismissBtn);
@@ -902,6 +1293,34 @@
902
1293
  return el;
903
1294
  }
904
1295
 
1296
+ // Task 9 review I3: MEASURED AT THE TIME — `.ed-conflict` was
1297
+ // `position:fixed; top:0; z-index:999` against `.ed-toolbar`'s
1298
+ // `top:0; height:44px; z-index:101`, so the keynav-exit banner sat OVER
1299
+ // the entire bar (23/23 buttons' centre points hit-tested to the banner,
1300
+ // not their own button) the moment it appeared. Task 10 (backlog #8)
1301
+ // since moved `.ed-conflict` to `top: var(--ed-toolbar-h)` — it no longer
1302
+ // covers the bar at all, for any banner, not just this one — so that
1303
+ // geometry claim is HISTORICAL, kept here only because it is what
1304
+ // motivated wiring Escape below; do not read it as the current state of
1305
+ // `.ed-conflict`. What is still current: a keyboard user had no way to
1306
+ // take this banner down other than a mouse click on ✕ — Escape did not
1307
+ // dismiss it (showBanner() only wires a click handler onto ✕) and a
1308
+ // bare Tab is swallowed by this file's own §3.5 contract whenever nothing
1309
+ // real is focused, which is exactly the state right after this banner's
1310
+ // own cause (a stray key that just exited keynav). Escape is the one key
1311
+ // safe to wire this to: every OTHER banner this file raises (conflict /
1312
+ // save-failed / a structural refusal) describes something that must
1313
+ // survive an incidental Escape elsewhere on the page, but THIS banner's
1314
+ // entire subject is "you just left keyboard mode" — Escape asking to
1315
+ // leave a mode a second time is the same request, not a different one.
1316
+ function dismissKeynavExitBanner() {
1317
+ if (!activeBanner || !activeBannerIsKeynavExit) return false;
1318
+ activeBanner.remove();
1319
+ activeBanner = null;
1320
+ activeBannerIsKeynavExit = false;
1321
+ return true;
1322
+ }
1323
+
905
1324
  // Review recommendation 5: a refusal banner must not outlive the gesture that
906
1325
  // raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
907
1326
  // S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
@@ -1104,6 +1523,13 @@
1104
1523
  // guard in restoreDiscardedBurst() would refuse it anyway; dropping it at
1105
1524
  // the source is what keeps a dead history object from being held.
1106
1525
  dropDiscardedBurst();
1526
+ // v3.4.0 (Task 7 item 5): same reasoning, same source, for the raw-editor
1527
+ // twin — this render is about to replace `lines`' rendering of whatever
1528
+ // discardedRawEdit's stashed `source` names.
1529
+ dropDiscardedRawEdit();
1530
+ // v3.4.0 batch3 Task 7: and the wave twin — its staleness guard compares
1531
+ // `lines` at a span, which this render is about to repaint from.
1532
+ dropDiscardedWaveEdit();
1107
1533
  const scrollY = window.scrollY;
1108
1534
  // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
1109
1535
  // for the rebuilt selection. Consumed HERE, before the first failure exit,
@@ -1182,10 +1608,17 @@
1182
1608
  // Its VALUE is forwarded to patchmap(), which never reads it (決議 4(b) — it
1183
1609
  // is advisory, and seeding the span from an op's line range measurably
1184
1610
  // 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.
1611
+ // affects the plan. The first commit after a page load is ALWAYS a full
1612
+ // render. That is specified behaviour, not a bug — but fix round 2
1613
+ // (re-review G2) corrects HOW it is carried: it used to be that `lastParts`
1614
+ // stayed null until a render landed, and null is what made this gate fall
1615
+ // through. Since the F1 fix, `lastParts` is seeded off the server-rendered
1616
+ // DOM at first paint and is NEVER null after that; the property is carried
1617
+ // by `lastPartsIsBootstrapSeed` instead (the seed is the HTML parser's
1618
+ // re-serialisation, not the server's own `parts` strings, so it must not
1619
+ // reach patchmap(), which diffs those strings). Do NOT use
1620
+ // `lastParts === null` as an "unprimed" test anywhere — it is now always
1621
+ // false.
1189
1622
  // Fix round 2: applyPatch()'s index arithmetic addresses `contentEl`'s
1190
1623
  // element children POSITIONALLY against `lastParts`. That correspondence is
1191
1624
  // not free — several gestures mutate .content's child list DIRECTLY, in the
@@ -1319,7 +1752,7 @@
1319
1752
  absorbedOutsideContent(lastRenderLines, blocks,
1320
1753
  lines, j.blocks,
1321
1754
  j.blocks[j.blocks.length - 1]);
1322
- const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
1755
+ const plan = (editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
1323
1756
  ? window.md2docPatchmap.patchmap({
1324
1757
  oldBlocks: blocks, newBlocks: j.blocks,
1325
1758
  oldParts: lastParts, newParts: j.parts, editRange: editRange,
@@ -1424,6 +1857,51 @@
1424
1857
  const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
1425
1858
  const focusWasInSpan = ci >= 0 &&
1426
1859
  ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
1860
+ // v3.4.0 (Task 7 item 1) — the raw-editor twin of applyFullRender()'s
1861
+ // v3.2.1 R2 `activeEditor = null` fix (see its comment), narrowed to the
1862
+ // one case that fix actually needs to cover here. That fix cannot be
1863
+ // copied verbatim: the whole point of the patch path is to let a raw
1864
+ // editor OUTSIDE replaceSpan survive (see "決議 3(1)" below) — nulling
1865
+ // activeEditor unconditionally here would re-orphan exactly the session
1866
+ // item1-scenarioB measured. The hazard is narrower: when activeEditor's
1867
+ // OWN block falls INSIDE replaceSpan, the removeChild loop below is
1868
+ // about to detach it out from under itself, and Chromium's synchronous
1869
+ // focusout for that detach finds `activeEditor` still set (commit()'s own
1870
+ // post-await `activeEditor = null` hasn't run yet — this render IS that
1871
+ // commit's own safeRerenderAll(), still in flight) — reentering
1872
+ // switchAwayFrom() -> resolveOpenSession() -> commitNow() a SECOND time.
1873
+ //
1874
+ // MEASURED (runs/task7-probe2-item1.js + task7-probe4-isolate.js, on a
1875
+ // 2nd-commit-in-session PATCH render that repairs a swallowed fence's
1876
+ // closing marker — the repair spawns a NEW block, same "衍生新 block"
1877
+ // shape the v3.2.1 R2 comment names): the reentrant commit() sees
1878
+ // `blocks` already reassigned to `j.blocks` (this function reassigns it
1879
+ // above, before the mutation below) — i.e. the block's NEW, shrunk
1880
+ // post-split range — but reuses `ta.value`, the STALE, un-shrunk text
1881
+ // that still carries the split-off tail. commitEdit() diffs that against
1882
+ // the (already-correct) `lines` and, since they no longer match, commits
1883
+ // a real op writing the stale tail-bearing text over the shrunk range —
1884
+ // duplicating the tail the FIRST (outer) commit had just placed in its
1885
+ // own new block a moment earlier. Three /api/render calls for one
1886
+ // Ctrl+Enter; the file gained a second 'Trailing paragraph text.'.
1887
+ // Nulling activeEditor here, before the mutation, for exactly the
1888
+ // falls-inside-replaceSpan case closes the hole the same way v3.2.1 R2
1889
+ // closed it for the fallback path, without touching the survives case.
1890
+ //
1891
+ // PRECONDITION worth stating explicitly: this can only fire on a PATCH
1892
+ // render, and patchmap() only runs once `lastParts` is populated — the
1893
+ // very first commit of a session is ALWAYS a full render (see
1894
+ // applyRenderResult()'s own comment). So the defect does NOT reproduce
1895
+ // on a freshly opened document with no prior commit in the same
1896
+ // session; repairing a swallowed fence as literally the first edit
1897
+ // after page load is safe (fallback path). See task-7-report.md and
1898
+ // this fix's test scenario for the measured before/after.
1899
+ if (activeEditor && activeEditor.blockEl) {
1900
+ const aci = children.indexOf(activeEditor.blockEl);
1901
+ if (aci >= plan.replaceSpan.oldStart && aci <= plan.replaceSpan.oldEnd) {
1902
+ activeEditor = null;
1903
+ }
1904
+ }
1427
1905
  // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1428
1906
  // between the module's model and the DOM, and they are overwritten BEFORE
1429
1907
  // the DOM is touched — armEditables() reads the new `blocks` (see its call
@@ -1534,8 +2012,15 @@
1534
2012
  // 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
1535
2013
  // 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
1536
2014
  // 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
1537
- // teardown 各項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1538
- // 仍然在 DOM 手術之後求值。
2015
+ // teardown 各項彼此的相對順序沒有被動到,**條件式**那一發 activeEditor
2016
+ // 歸零仍然在 DOM 手術之後求值——但這不再是這次 render 唯一可能歸零
2017
+ // `activeEditor` 的地方:v3.4.0 (Task 7 item 1) 在 DOM 手術**之前**另外
2018
+ // 補了一道 pre-mutation 的窄範圍歸零(見 `focusWasInSpan` 正下方那段
2019
+ // `if (activeEditor && activeEditor.blockEl) { … }`),只在
2020
+ // `activeEditor.blockEl` 落在 `plan.replaceSpan` 內時才提前清空。兩者
2021
+ // 不衝突(提前清過的話這裡條件式那段的 `activeEditor &&` 直接短路),但
2022
+ // 讀這段時不要再假設「這次 render 裡 activeEditor 的歸零只有一處、而且
2023
+ // 一定在 DOM 手術之後」。
1539
2024
  const newLen = plan.replaceSpan.newParts.length;
1540
2025
  if (newLen > 0) {
1541
2026
  // landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
@@ -1821,6 +2306,23 @@
1821
2306
  // Never let a reader-runtime rebind failure break block selection/save/undo.
1822
2307
  }
1823
2308
  }
2309
+ // v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
2310
+ // __md2docInitDiagrams above, and for the same reason — the swap just
2311
+ // detached every node the multi-page drawio navigation was mounted on,
2312
+ // and nothing else in the tree re-creates it. Left unwired, a document
2313
+ // with a multi-page diagram loses that navigation permanently on the
2314
+ // user's FIRST edit commit of the session, which makes preserving the
2315
+ // reader's open page across a background re-bake largely academic.
2316
+ // Defined only when the document was multi-page at load time (the
2317
+ // lib/md2doc.js gate a byte-exact guard test pins), hence the check.
2318
+ if (window.__md2docInitDrawioSheetBar) {
2319
+ try {
2320
+ window.__md2docInitDrawioSheetBar(contentEl);
2321
+ } catch (e) {
2322
+ // Same contract as the two rebinds around it: never let a
2323
+ // navigation-rebuild failure break block selection / save / undo.
2324
+ }
2325
+ }
1824
2326
  // Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
1825
2327
  // above does NOT take its early-return path it has already called
1826
2328
  // applySelectionClasses() once, via setBlockSelection()/
@@ -1887,6 +2389,7 @@
1887
2389
  async function applyFullRender(j, scrollY, declaredRange) {
1888
2390
  blocks = j.blocks;
1889
2391
  lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
2392
+ lastPartsIsBootstrapSeed = false;
1890
2393
  // v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
1891
2394
  // textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
1892
2395
  // commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
@@ -2029,6 +2532,23 @@
2029
2532
  // Never let a reader-runtime rebind failure break block selection/save/undo.
2030
2533
  }
2031
2534
  }
2535
+ // v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
2536
+ // __md2docInitDiagrams above, and for the same reason — the swap just
2537
+ // detached every node the multi-page drawio navigation was mounted on,
2538
+ // and nothing else in the tree re-creates it. Left unwired, a document
2539
+ // with a multi-page diagram loses that navigation permanently on the
2540
+ // user's FIRST edit commit of the session, which makes preserving the
2541
+ // reader's open page across a background re-bake largely academic.
2542
+ // Defined only when the document was multi-page at load time (the
2543
+ // lib/md2doc.js gate a byte-exact guard test pins), hence the check.
2544
+ if (window.__md2docInitDrawioSheetBar) {
2545
+ try {
2546
+ window.__md2docInitDrawioSheetBar(contentEl);
2547
+ } catch (e) {
2548
+ // Same contract as the two rebinds around it: never let a
2549
+ // navigation-rebuild failure break block selection / save / undo.
2550
+ }
2551
+ }
2032
2552
  setDirty();
2033
2553
  return true;
2034
2554
  }
@@ -2440,6 +2960,47 @@
2440
2960
  async function cancelAndMaybeDiscard() {
2441
2961
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
2442
2962
  if (wasPristineForThisBlock) pristineInsert = null;
2963
+ // v3.4.0 (Task 7 item 5): stash what is about to be thrown away, keyed
2964
+ // by VALUE rather than by node — see discardedRawEdit's own comment for
2965
+ // why a node-keyed stash (discardedBurst's own approach) cannot survive
2966
+ // this function's own restore() just below: for the non-rangeMode
2967
+ // branch it does `blockEl.innerHTML = original`.
2968
+ //
2969
+ // ⚠ rangeMode (a multi-block span, or any li) is NOT covered by this
2970
+ // stash, and that is a known, unfixed gap rather than something this
2971
+ // branch happens to also solve — review round 2 measured it on HEAD:
2972
+ // restore()'s rangeMode branch calls `await safeRerenderAll()`, which
2973
+ // reaches rerenderAll(), which calls dropDiscardedRawEdit()
2974
+ // UNCONDITIONALLY a couple of lines into the very Escape gesture that
2975
+ // just created the stash below. So the `discardedRawEdit = {...}`
2976
+ // assignment two lines down is live code but its EFFECT is dead for
2977
+ // every rangeMode session — the stash exists for one statement and is
2978
+ // wiped by its own creator's next `await`. A single li ( -item one ->
2979
+ // ⠿ -> MD 原始碼 -> type -> Escape -> Ctrl+Z) and a multi-block span
2980
+ // both measure `rawAfterUndo: {present:false}` — Ctrl+Z does not bring
2981
+ // the text back on either shape. Fixing this is out of this task's
2982
+ // scope (parked to backlog); this comment exists so nobody reads the
2983
+ // code below as covering rangeMode when it structurally cannot.
2984
+ // Skipped for a pristine insert: discardPristineInsert() (below)
2985
+ // REMOVES the block outright, so there is no surface left to restore a
2986
+ // stash INTO — mirrors revertBurstAndEnd()'s identical
2987
+ // wasPristineForThisBlock exception for the WYSIWYG burst twin.
2988
+ // Skipped when nothing was actually typed (ta.value === seed.value):
2989
+ // the zero-edit auto-cancel-on-unchanged-blur path in
2990
+ // resolveOpenSession() reaches this same function, and there is
2991
+ // nothing to lose there.
2992
+ if (!wasPristineForThisBlock && ta.value !== seed.value) {
2993
+ // Mutual exclusivity with the WYSIWYG twin — see discardedRawEdit's
2994
+ // own comment for why only the more recent of the two stashes may
2995
+ // ever answer the next Ctrl+Z.
2996
+ dropDiscardedBurst();
2997
+ // …and with the wave twin, for the same reason.
2998
+ dropDiscardedWaveEdit();
2999
+ discardedRawEdit = {
3000
+ blockId, range, startLine, endLine, source,
3001
+ value: ta.value, caret: ta.selectionStart,
3002
+ };
3003
+ }
2443
3004
  await restore();
2444
3005
  if (wasPristineForThisBlock) return await discardPristineInsert();
2445
3006
  return true;
@@ -4346,6 +4907,10 @@
4346
4907
  await openRawEditor(blockEl);
4347
4908
  return;
4348
4909
  }
4910
+ // NOTE: `kind === 'line'` never reaches here — insertBlockBelow()'s own
4911
+ // caller excludes it (`if (!customLines && kind !== 'line')`) and
4912
+ // handles the hr's caret landing itself, right where it computes
4913
+ // `result.newStartLine`; see that branch's own backlog #6 comment.
4349
4914
  if (kind === 'table') {
4350
4915
  const tableEl = blockContentEl(blockEl);
4351
4916
  const firstBodyRow = tableEl ? bodyRowsOf(tableEl)[0] : null;
@@ -4481,7 +5046,14 @@
4481
5046
  const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
4482
5047
  const block = blocks.find((b) => b.id === liveBlockId);
4483
5048
  if (!block) return;
4484
- const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
5049
+ // `tight` is only ever honoured when the anchor really is a list item:
5050
+ // the caller decides the CONTENT is list-shaped, this decides the
5051
+ // DESTINATION is a list. Both halves are needed — tight-inserting a list
5052
+ // under a paragraph would glue it to that paragraph's last line.
5053
+ const tight = !!(opts && opts.tight) &&
5054
+ anchorEl.getAttribute('data-block-type') === 'li';
5055
+ const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines,
5056
+ { tight });
4485
5057
  const prevLines = lines;
4486
5058
  lines = result.lines;
4487
5059
  const editRange = result.op
@@ -4537,7 +5109,24 @@
4537
5109
  // no declareCollapse(), so re-aiming the bar at the hr cannot contradict
4538
5110
  // a tinted set the way a forced caret would — but "the one site that does
4539
5111
  // not ask the question" is how the next divergence gets in.
4540
- if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
5112
+ if (!blockSelection) {
5113
+ reaimToolbarBlockAtLine(result.newStartLine);
5114
+ // backlog #6: the "restore the caret too" decision the comment above
5115
+ // deferred. armEditables() has no 'line' branch (same degraded shape
5116
+ // as 'code' — see focusInsertedBlock()'s own branch for the
5117
+ // established precedent), so an <hr> has no contenteditable surface
5118
+ // to land a caret in at all; its own raw editor is the only real
5119
+ // landing. MEASURED (v3.3.0, this comment's own log above):
5120
+ // activeElement stayed BODY whether this branch ran or not — this
5121
+ // closes exactly that gap rather than picking "the anchor block" or
5122
+ // "the block after the hr", the two options the deferred comment
5123
+ // named: neither is where the user's attention actually is right
5124
+ // after pressing 分隔線, which is the hr itself.
5125
+ const target = blocks.find((b) => b.startLine === result.newStartLine);
5126
+ const hrEl = target &&
5127
+ document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5128
+ if (hrEl) await openRawEditor(hrEl);
5129
+ }
4541
5130
  }
4542
5131
  }
4543
5132
 
@@ -4926,7 +5515,32 @@
4926
5515
  // this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
4927
5516
  // caught by safeRerenderAll(): that can leave the DOM half-swapped, and
4928
5517
  // neither the old behaviour nor this one has an answer for it.
4929
- function restoreAfterStructuralOp(focusLine, anchorLine) {
5518
+ // `allowRawEditRescue` (Task 9 review I4): the backlog #6 raw-editor
5519
+ // fallback below is opt-in per CALLER, not a blanket behaviour of this
5520
+ // shared landing function. restoreAfterStructuralOp() has four callers —
5521
+ // changeHeadingDepth / convertBlockViaMenu / duplicateBlockViaMenu /
5522
+ // deleteBlockViaGutter — and backlog #6's own text names exactly one
5523
+ // gesture family (轉換成 quote/code), reached only through
5524
+ // convertBlockViaMenu(). MEASURED (review): the guard as first written
5525
+ // was unconditional and DID also fire for 建立副本 (duplicate) on a code
5526
+ // block or a blockquote, and for 刪除 (delete) when the surviving
5527
+ // neighbour was a blockquote — BODY → TEXTAREA.ed-raw in all three,
5528
+ // philosophically consistent (armEditables() never arms quote/code
5529
+ // regardless of which gesture produced one) but never asked for, never
5530
+ // reported, and never tested.
5531
+ // Final-review I2 correction: this paragraph used to also cite backlog #5
5532
+ // (the raw editor's own Escape discarding an uncommitted edit) as a
5533
+ // reason auto-opening one right after a DESTRUCTIVE gesture
5534
+ // (duplicate/delete) would not be free the way it is right after a plain
5535
+ // conversion. Backlog #5 was fixed by 3eb36f9, which landed BEFORE this
5536
+ // very commit on this branch — Escape out of a raw editor no longer
5537
+ // discards the edit, so that risk no longer applies. Retracted rather than
5538
+ // left to mislead whoever next considers widening this rescue. The
5539
+ // narrowing itself still stands on its own merits above (never asked for,
5540
+ // never reported, never tested for duplicate/delete) — widening past
5541
+ // convertBlockViaMenu() to the other three callers remains a separate
5542
+ // decision this task did not make and should not make silently.
5543
+ function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
4930
5544
  // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
4931
5545
  // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
4932
5546
  if (blockSelection) return;
@@ -4935,6 +5549,27 @@
4935
5549
  // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
4936
5550
  // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
4937
5551
  reaimToolbarBlockAtLine(focusLine);
5552
+ // backlog #6: focusBlockAtLine()'s no-op above is exactly the failure
5553
+ // mode this closes — armEditables() never arms quote/code (no branch
5554
+ // for either in its own condition), so blockContentEl() there hands
5555
+ // back a plain, non-focusable <blockquote>/<pre> and .focus() on it
5556
+ // is silently inert. MEASURED (v3.3.0): 轉換成 quote/code/line left
5557
+ // the caret on BODY with NOTHING to click into — "a mouse user climbs
5558
+ // out by clicking into the text" (activateToolbarCursor()'s own
5559
+ // comment) does not even apply here, since there is no editable text
5560
+ // to click into. The established way to edit either type at all is
5561
+ // the ⠿ menu's own MD 原始碼 raw editor (openRawEditor(), the same
5562
+ // one focusInsertedBlock()'s `kind === 'code'` branch already opens
5563
+ // for a freshly INSERTED code block) — reused here rather than
5564
+ // invented, so the landing this action leaves someone in is the
5565
+ // block's real, only editing surface, not a caret parked over
5566
+ // uneditable prose.
5567
+ if (allowRawEditRescue && document.activeElement === document.body) {
5568
+ const target = blocks.find((b) => b.startLine === focusLine);
5569
+ const blockEl = target &&
5570
+ document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5571
+ if (blockEl) openRawEditor(blockEl).catch(() => {});
5572
+ }
4938
5573
  return;
4939
5574
  }
4940
5575
  if (anchorLine == null) return;
@@ -5041,7 +5676,10 @@
5041
5676
  // 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
5042
5677
  // 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
5043
5678
  // 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
5044
- restoreAfterStructuralOp(focusLine, anchor.line);
5679
+ // Task 9 review I4: `true` — this is the ONE call site backlog #6's
5680
+ // raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
5681
+ // own `allowRawEditRescue` comment.
5682
+ restoreAfterStructuralOp(focusLine, anchor.line, true);
5045
5683
  }
5046
5684
  }
5047
5685
 
@@ -6269,6 +6907,13 @@
6269
6907
 
6270
6908
  function startBurst(editEl) {
6271
6909
  dropDiscardedBurst();
6910
+ // v3.4.0 (Task 7 item 5): opening ANY new session — burst or raw —
6911
+ // invalidates a still-parked raw-edit discard from a different block; see
6912
+ // discardedRawEdit's own comment for why the two stashes must stay
6913
+ // mutually exclusive.
6914
+ dropDiscardedRawEdit();
6915
+ // v3.4.0 batch3 Task 7: …and the wave twin, same rule, three stashes.
6916
+ dropDiscardedWaveEdit();
6272
6917
  const blockEl = editEl.closest('.ed-block');
6273
6918
  if (!blockEl) return;
6274
6919
  const blockId = Number(blockEl.getAttribute('data-block-id'));
@@ -6714,6 +7359,19 @@
6714
7359
  }
6715
7360
  function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
6716
7361
 
7362
+ // Whether a key event landed in something that takes typed characters —
7363
+ // ANY such thing, wherever it is mounted. The block-selection key branch
7364
+ // documents "focus is on a block wrapper and not on any text surface" as its
7365
+ // precondition; this is that sentence as a check. `isContentEditable` covers
7366
+ // the WYSIWYG surfaces without naming their classes, and the three tag names
7367
+ // cover controls mounted anywhere, `.content` or not.
7368
+ function isTextEntryTarget(el) {
7369
+ if (el === null || el === undefined) return false;
7370
+ if (el.isContentEditable === true) return true;
7371
+ const tag = el.tagName;
7372
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
7373
+ }
7374
+
6717
7375
  // ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
6718
7376
  // `contentEl.innerHTML` is replaced wholesale on every commit, so the
6719
7377
  // painted tint and the roving focus holder are destroyed by definition —
@@ -7493,6 +8151,10 @@
7493
8151
  burst.history.flushTyping();
7494
8152
  editEl.innerHTML = burst.original;
7495
8153
  burst.history.snap('escape-discard');
8154
+ // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8155
+ // see discardedRawEdit's own comment.
8156
+ dropDiscardedRawEdit();
8157
+ dropDiscardedWaveEdit();
7496
8158
  discardedBurst = {
7497
8159
  history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
7498
8160
  reverted: burstBaselineHtml(editEl),
@@ -7511,6 +8173,10 @@
7511
8173
  burst.history.flushTyping();
7512
8174
  revertFn();
7513
8175
  burst.history.snap('escape-discard');
8176
+ // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8177
+ // see discardedRawEdit's own comment.
8178
+ dropDiscardedRawEdit();
8179
+ dropDiscardedWaveEdit();
7514
8180
  discardedBurst = {
7515
8181
  history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
7516
8182
  reverted: burstBaselineHtml(tableEl),
@@ -7580,6 +8246,62 @@
7580
8246
  return true;
7581
8247
  }
7582
8248
 
8249
+ // v3.4.0 (Task 7 item 5) — the raw-editor twin of restoreDiscardedBurst()
8250
+ // just above. Ctrl+Z with no burst/raw editor open, when the last thing
8251
+ // that happened was an Escape/✕ out of a raw editor: RE-OPEN that same raw
8252
+ // editor and put the discarded text back into it — a live, dirty edit
8253
+ // again (hasChanges() reads true against the fresh seed, so the next
8254
+ // blur/Ctrl+Enter commits it, and a further Ctrl+Z steps back through the
8255
+ // reopened session same as any other raw edit). Escape's own semantics —
8256
+ // discard, and leave — are unchanged; only "gone forever" is.
8257
+ //
8258
+ // Staleness guard is asked at the `lines`/`blocks` level rather than the
8259
+ // DOM level restoreDiscardedBurst() uses (`document.body.contains(editEl)`
8260
+ // + a baseline-HTML comparison) — openRawEditor()'s own restore() tears
8261
+ // the textarea's DOM down unconditionally (see discardedRawEdit's own
8262
+ // comment), so there is no live node whose presence would even answer the
8263
+ // question. What has to hold instead is SIMILAR IN SPIRIT to what
8264
+ // reanchor() (this same file, openRawEditor()'s own re-derivation for a
8265
+ // session that survives a patch) asks — same two questions, "does the
8266
+ // block id still name a block?" and "are the bytes at its span still what
8267
+ // this session last saw?" — but it is NOT the same predicate, and the two
8268
+ // must not be conflated: reanchor()'s very first line is
8269
+ // `if (range) return false;`, i.e. it refuses EVERY rangeMode session
8270
+ // outright. The check below has no such refusal — for a single-block
8271
+ // (non-range) discard it asks the two questions above and nothing else.
8272
+ // In practice this asymmetry is moot for rangeMode: discardedRawEdit is
8273
+ // never actually populated for a rangeMode session in the first place
8274
+ // (see cancelAndMaybeDiscard()'s own comment — restore()'s rangeMode
8275
+ // branch wipes the stash via rerenderAll()'s unconditional
8276
+ // dropDiscardedRawEdit() before a Ctrl+Z could ever reach here), so this
8277
+ // function's range-shaped `d.range` field is carried for openRawEditor()'s
8278
+ // signature and is never exercised by a live stash today. Returns false
8279
+ // whenever there is nothing to restore or the stash has gone stale; the
8280
+ // caller then falls through to the ordinary document undo, same contract
8281
+ // as restoreDiscardedBurst().
8282
+ async function restoreDiscardedRawEdit() {
8283
+ const d = discardedRawEdit;
8284
+ discardedRawEdit = null;
8285
+ if (!d) return false;
8286
+ const b = blocks.find((x) => x.id === d.blockId);
8287
+ if (!b || b.startLine !== d.startLine || b.endLine !== d.endLine) return false;
8288
+ if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
8289
+ const blockEl = blockElById(d.blockId);
8290
+ if (!blockEl) return false;
8291
+ await openRawEditor(blockEl, d.range);
8292
+ // Re-queried rather than reusing `blockEl` above: openRawEditor()'s own
8293
+ // guard may have resolved a DIFFERENT open session first (switchAwayFrom()),
8294
+ // which can re-render and hand out a fresh node for the same block id.
8295
+ const liveBlockEl = blockElById(d.blockId);
8296
+ const ta = liveBlockEl && liveBlockEl.querySelector('.ed-raw');
8297
+ if (!ta) return false; // a refusal (or a lost race) beat us to it
8298
+ ta.value = d.value;
8299
+ const caret = Math.max(0, Math.min(d.value.length, d.caret));
8300
+ ta.setSelectionRange(caret, caret);
8301
+ autoSize(ta);
8302
+ return true;
8303
+ }
8304
+
7583
8305
  // Ctrl+Z inside a burst: step the burst-local history first; only once
7584
8306
  // it's exhausted (atBottom — the surface is already back to its pre-focus
7585
8307
  // baseline) does this cascade OUT to the document-level undo() stack,
@@ -8757,6 +9479,9 @@
8757
9479
  // re-armed table, not a plain static one).
8758
9480
  function startTableBurst(cellEl) {
8759
9481
  dropDiscardedBurst();
9482
+ // v3.4.0 (Task 7 item 5): see startBurst()'s own comment just above.
9483
+ dropDiscardedRawEdit();
9484
+ dropDiscardedWaveEdit();
8760
9485
  const tableEl = cellEl.closest('table');
8761
9486
  const blockEl = tableEl && tableEl.closest('.ed-block');
8762
9487
  if (!blockEl) return;
@@ -8823,9 +9548,13 @@
8823
9548
  // time this focusin resumes. Instrumented on the F8 fixture (a
8824
9549
  // paragraph left dirty, then a press into the table): the mousedown's
8825
9550
  // 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 =`
9551
+ // gate consults domMatchesLastRender(). MEASURED AT THE TIME, when
9552
+ // `lastParts` was still null until a render landed and that is what made
9553
+ // the gate report false. Fix round 2 (re-review G2): `lastParts` is
9554
+ // seeded at first paint now and is never null, so the route below is
9555
+ // reached through `lastPartsIsBootstrapSeed` instead — the ROUTE and the
9556
+ // symptom are unchanged, only the reason the gate declines. The render
9557
+ // goes to applyFullRender(), whose `contentEl.innerHTML =`
8829
9558
  // rewrites .content wholesale. A probe around the `await` above
8830
9559
  // recorded route ["applyFullRender"] together with
8831
9560
  // `document.body.contains(cellEl)` false, on each reproduction driven.
@@ -9372,28 +10101,104 @@
9372
10101
  // here then names a DIFFERENT block. `classList.contains('ed-wys-table')`
9373
10102
  // was the only guard, and every table passes it, so the gesture silently
9374
10103
  // 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.
10104
+ // its body rows to its header). The fix added `startLine` — see
10105
+ // blockElAtLine()'s own comment — and a real identity check (header cell
10106
+ // count, header cell text, row count) before handing the block to a caller
10107
+ // that is about to mutate it.
10108
+ //
10109
+ // v3.4.0 Task 12: `startLine` ALONE carries the same hazard, one shape
10110
+ // over. DRIVEN, not argued (the v3.3.0 final review tried and could not,
10111
+ // because its fixture's commit never detached the table, so this branch
10112
+ // never ran at all): a six-line paragraph above the table, its raw editor
10113
+ // left dirty with a ONE-line replacement, then a row drag on the table.
10114
+ // The drop's own switchAwayFrom() commits that raw edit — the session's
10115
+ // first render, so it is a full `.content` rewrite and `tableEl` really is
10116
+ // detached here — and every block below moves up 5 lines. With the two
10117
+ // tables 5 lines apart, the SECOND table's new startLine is exactly the
10118
+ // first one's captured one; with matching header cells and row counts,
10119
+ // tableIdentityOf() agrees; and the drag reordered the neighbour's rows on
10120
+ // screen and on disk while the dragged table sat untouched. Instrumented
10121
+ // run and assertion: test/table-anchor-recovery.test.js.
10122
+ //
10123
+ // So this asks the SAME composite anchor handleTableCellFocusIn() above
10124
+ // asks, for the same reason its comment writes down — the two handles fail
10125
+ // on DIFFERENT commits (a line added ahead of the table with the block
10126
+ // count unchanged breaks startLine but not the id; a block added ahead of
10127
+ // it with the line count unchanged breaks the id but not startLine), so
10128
+ // neither subsumes the other and the id is asked first. Whichever answers,
10129
+ // tableIdentityOf() still has to agree. A commit that defeats BOTH handles
10130
+ // leaves nothing to resolve; returning null drops the gesture, which is
10131
+ // the conservative half of the trade — never mutating the wrong table
10132
+ // beats completing every gesture.
10133
+ // v3.4.0 Task 12: this used to be row count + HEADER cell text only, and
10134
+ // that is exactly as much as two different tables in one document can
10135
+ // share by accident — "| A | B |" over two rows describes an enormous
10136
+ // number of real tables, and a document that repeats a header (a register
10137
+ // map, a pin list, one table per block) has several. Both call sites use
10138
+ // this to decide whether a block they re-resolved by a possibly-stale
10139
+ // handle is the table the gesture started on, so a collision there is not
10140
+ // a near miss: the row drag Task 12 drove reordered the NEIGHBOUR's rows,
10141
+ // on screen and on disk, while the dragged table sat untouched. Every
10142
+ // row's cells go in, so two tables collide only when their rendered text
10143
+ // is identical cell for cell.
10144
+ //
10145
+ // The commit being recovered from is always some OTHER block's (a burst on
10146
+ // THIS table early-returns before any of this), so this table's own
10147
+ // rendered text is what it was when the handle was captured — the stronger
10148
+ // identity refuses collisions without refusing the table itself.
10149
+ //
10150
+ // The delimiters are written as ESCAPES, not as literal control bytes.
10151
+ // They used to be literal, which made grep classify this whole 9643-line
10152
+ // file as binary and SILENTLY TRUNCATE its output — a real investigation
10153
+ // was misled by it. The runtime value is identical.
9384
10154
  function tableIdentityOf(tableEl) {
9385
10155
  const headerRow = tableEl ? headerRowOf(tableEl) : null;
9386
10156
  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) {
10157
+ const rows = allRowsOf(tableEl);
10158
+ return rows.length + '\x01' + rows.map((r) =>
10159
+ Array.prototype.slice.call(r.cells).map((c) => c.textContent).join('\x00')).join('\x02');
10160
+ }
10161
+ // v3.4.0 §2: `forDrag` narrows the policy above for the ROW/COLUMN DRAG
10162
+ // callers only (performRowDrop()/performColDrop() below) — every other
10163
+ // caller (the insert-row/insert-column hover bubbles, cycleAlign(),
10164
+ // deleteRow/deleteColumn's own callers) keeps focusing cells[0] with a
10165
+ // normal, scrolling focus() exactly as before, unchanged.
10166
+ //
10167
+ // Why a drag needs a DIFFERENT policy, not just a smaller guard elsewhere:
10168
+ // a drag gesture never had a focused cell of its own, yet THIS function's
10169
+ // own `cell.focus()` a few lines down is what actually opens the burst in
10170
+ // the first place — the delegated focusin listener
10171
+ // (handleTableCellFocusIn() above) is the ONLY path that calls
10172
+ // startTableBurst(), so the call can't simply be skipped for a drag; the
10173
+ // burst would never open at all, and every existing row/column drag
10174
+ // scenario that asserts "a drop auto-starts a burst" (a cell must be
10175
+ // focused afterward) would break. What can change for a drag, since it
10176
+ // never had an opinion about WHICH cell should end up focused, is WHICH
10177
+ // cell this picks and HOW it focuses it:
10178
+ // - `{ preventScroll: true }` — a plain focus() with no options scrolls
10179
+ // its target into view. A stack-traced probe (measured, not guessed —
10180
+ // see performRowDrop()'s own comment below for the full write-up)
10181
+ // found this is only HALF the "drag jumps to the header" symptom's
10182
+ // origin: restoreTableFocus() (below) always re-resolves and
10183
+ // re-focuses the SAME cell this call already focused — there is no
10184
+ // way to skip that second focus() by checking an index, since the
10185
+ // index it would check is always valid by the time it's read (same
10186
+ // comment). So restoreTableFocus()'s own focus() ALSO needs
10187
+ // `preventScroll: true` — this call alone was never going to be
10188
+ // enough.
10189
+ // - the first BODY cell (a <td>), not cells[0] (the header <th>) — a
10190
+ // table drag's own regression scenario asserts the resulting active
10191
+ // element is not a header cell. Every cell (header or body) already
10192
+ // carries 'ed-wys-cell' (armEditables()'s table branch, arm time), so
10193
+ // a body cell satisfies "a drop auto-starts a burst" exactly as well
10194
+ // as the header did — and restoreTableFocus() re-focuses whichever
10195
+ // cell THIS picks, so picking a body cell here is what keeps its own
10196
+ // re-focus off the header too. Falls back to cells[0] only when the
10197
+ // table has no body row at all (nothing to drag in that case anyway).
10198
+ async function ensureTableBurstOpen(tableEl, forDrag) {
9395
10199
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
9396
10200
  const blockEl = tableEl.closest('.ed-block');
10201
+ const blockIdAttr = blockEl ? blockEl.getAttribute('data-block-id') : null;
9397
10202
  const blockId = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
9398
10203
  const block = blockId != null ? blocks.find((b) => b.id === blockId) : null;
9399
10204
  const startLine = block ? block.startLine : null;
@@ -9402,24 +10207,56 @@
9402
10207
  if (!ok) return null;
9403
10208
  let liveTableEl = tableEl;
9404
10209
  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
10210
+ // Both handles are gated by the SAME identity check, so a candidate
10211
+ // that is not a live armed table, or is a table this gesture never
10212
+ // touched, simply does not answer and the next handle gets its turn —
10213
+ // rather than each handle carrying its own copy of the two refusals.
10214
+ const isThisTable = (t) => !!(t && t.classList &&
10215
+ t.classList.contains('ed-wys-table') && identity != null &&
10216
+ tableIdentityOf(t) === identity);
10217
+ const liveTableFrom = (candidateBlockEl) => {
10218
+ const t = candidateBlockEl ? blockContentEl(candidateBlockEl) : null;
10219
+ return isThisTable(t) ? t : null;
10220
+ };
10221
+ liveTableEl =
10222
+ liveTableFrom(blockIdAttr != null
10223
+ ? document.querySelector('.ed-block[data-block-id="' + blockIdAttr + '"]') : null) ||
10224
+ liveTableFrom(startLine != null ? blockElAtLine(startLine) : null);
10225
+ // v3.4.0 Task 12, second half: identity says "this LOOKS like the table
10226
+ // the gesture started on", and a handle says "and it is at the place I
10227
+ // remember". Neither says the answer was the only one available. DRIVEN
10228
+ // on two byte-identical tables (a shape a register map or a pin list
10229
+ // produces by itself) with the gesture on the SECOND one: the commit
10230
+ // renumbered the ids, the captured id landed on the FIRST twin, and
10231
+ // identity — which cannot tell twins apart, however much of the table
10232
+ // it hashes — waved it through. The drag reordered the first table's
10233
+ // rows and left the dragged one alone, on screen and on disk.
10234
+ //
10235
+ // So when the document holds more than one table this gesture could
10236
+ // have started on, no handle is allowed to break the tie: nothing here
10237
+ // knows which twin the user pressed, and the wrong guess rewrites a
10238
+ // table they never touched. Refuse instead — the same conservative half
10239
+ // of the trade this whole recovery already takes. Counted over
10240
+ // `contentEl` (not the whole document) so the grip/bubble chrome
10241
+ // outside it can never be mistaken for a candidate.
10242
+ if (liveTableEl && Array.prototype.slice.call(
10243
+ contentEl.querySelectorAll('.ed-wys-table')).filter(isThisTable).length !== 1) {
10244
+ liveTableEl = null;
10245
+ }
10246
+ // T7: the refusal used to `return null` in silence — the drag, the
9408
10247
  // insert, the alignment change simply did not happen and nothing on
9409
10248
  // screen said why, which is indistinguishable from a broken control.
9410
10249
  // Dropping is still the right answer (see the S1 comment above); saying
9411
10250
  // 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) {
10251
+ if (!liveTableEl) {
9416
10252
  showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
9417
10253
  }
9418
10254
  }
9419
10255
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
9420
- const cell = tableCellsOf(liveTableEl)[0];
10256
+ const cells = tableCellsOf(liveTableEl);
10257
+ const cell = forDrag ? (cells.find((c) => c.tagName !== 'TH') || cells[0]) : cells[0];
9421
10258
  if (!cell) return null;
9422
- cell.focus();
10259
+ if (forDrag) cell.focus({ preventScroll: true }); else cell.focus();
9423
10260
  return (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl)
9424
10261
  ? liveTableEl : null;
9425
10262
  }
@@ -9449,8 +10286,40 @@
9449
10286
  // resolves to (which may differ from `tableEl` if a dirty burst
9450
10287
  // elsewhere just committed and swapped `.content` out from under this
9451
10288
  // click) — never the possibly-now-detached `tableEl` itself.
10289
+ //
10290
+ // v3.4.0 backlog #2 sister fix: same root cause as
10291
+ // onRowInsertBubbleClick() below — `colIndex` is set by
10292
+ // updateTableInsertBubbles() at an EARLIER hover and never re-validated
10293
+ // against the live table here. Unlike `afterRowIndex`, `colIndex` has
10294
+ // NO legal sentinel value at all (no "-1" case; updateTableInsertBubbles()
10295
+ // only ever assigns 0..headerCells.length-1, each meaning "insert after
10296
+ // column i"), so every value this dataset can legitimately hold must
10297
+ // name a real column on `liveTableEl`. A stale colIndex that no longer
10298
+ // does doesn't crash — insertColumn()'s `row.cells[colIndex]` lookup
10299
+ // silently comes back undefined, and `row.insertBefore(cell, ref ?
10300
+ // ref.nextSibling : null)` falls to its `null` branch, silently
10301
+ // APPENDING the new column at the very END of the table instead of
10302
+ // dropping the gesture. MEASURED (task-6-report.md): unreachable via
10303
+ // real gesture today, guarded by the exact same two independent
10304
+ // mechanisms as the row sibling (updateTableInsertBubbles()'s own
10305
+ // proximity-hide, and rerenderAll()'s unconditional
10306
+ // hideTableInsertBubbles()/hideTableGrips() on every full render) — kept
10307
+ // as defense-in-depth against the day either one is weakened, same
10308
+ // reasoning as the row fix.
9452
10309
  const liveTableEl = await ensureTableBurstOpen(tableEl);
9453
10310
  if (!liveTableEl) return;
10311
+ const headerRow = headerRowOf(liveTableEl);
10312
+ const targetCell = headerRow ? headerRow.cells[colIndex] : null;
10313
+ // No sentinel to exclude here (contrast afterRowIndex: -1 above) — any
10314
+ // colIndex that doesn't name a real column on the live table is stale.
10315
+ // Same policy as the DROPPED_GESTURE_MESSAGE precedent this file already
10316
+ // uses throughout (performBlockDrop()/performListItemDrop() et al.,
10317
+ // 15 call sites): can't relocate it on the live table -> drop the
10318
+ // gesture and say so, never guess.
10319
+ if (!targetCell) {
10320
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
10321
+ return;
10322
+ }
9454
10323
  currentBurst.history.snap('insert-col-pre');
9455
10324
  insertColumn(liveTableEl, colIndex);
9456
10325
  armNewTableCells(liveTableEl);
@@ -9467,22 +10336,42 @@
9467
10336
  // Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
9468
10337
  // above — the table element reference has to be re-resolved.
9469
10338
  //
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.
10339
+ // ⚠ RETRACTION, resolved in v3.4.0 (backlog #2). An earlier revision of
10340
+ // this comment went on to say that `afterRowIndex`, being a plain
10341
+ // integer, stays valid across that swap. Ruling T3-9 measured the
10342
+ // opposite: `afterRowIndex` is set by updateTableInsertBubbles() at
10343
+ // HOVER time, which can be arbitrarily earlier than this click — the
10344
+ // table's row count can shrink in between (its own delete-row, an undo,
10345
+ // anything) without a fresh mousemove ever repositioning the bubble to
10346
+ // notice. `tableIdentityOf()`'s row-count component (used by
10347
+ // ensureTableBurstOpen()'s own detached-table guard below) does NOT
10348
+ // catch this: identity is captured from `tableEl` at THIS click, in the
10349
+ // same synchronous prefix as `afterRowIndex` was just read above — it
10350
+ // always matches whatever the table looks like right now, so a handover
10351
+ // whose SOURCE the row count came from is unrelated to this table passes
10352
+ // it clean. `bodyRowsOf(liveTableEl)[afterRowIndex]` is the only place
10353
+ // that can see the stale index was for a row that no longer exists — a
10354
+ // naive `afterRow` falsy check does not distinguish "no row after this
10355
+ // one" (afterRowIndex: -1, `null` deliberately) from "this index no
10356
+ // longer names anything" (`undefined`), and insertRow() treats both
10357
+ // identically: `tbody.insertBefore(newRow, tbody.firstChild)`, i.e. the
10358
+ // row lands at the TOP of the table instead of the gesture being
10359
+ // dropped. Task 14's hide-on-approach fix (see updateTableInsertBubbles()
10360
+ // below) made this bubble reliably pressable, which widened the exposure
10361
+ // of this deferred defect rather than narrowing it.
9483
10362
  const liveTableEl = await ensureTableBurstOpen(tableEl);
9484
10363
  if (!liveTableEl) return;
9485
10364
  const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
10365
+ // afterRowIndex >= 0 names a SPECIFIC row that must still exist on the
10366
+ // live table; afterRowIndex === -1 deliberately produces a `null`
10367
+ // afterRow (the header-boundary/first-body-row case) and must NOT be
10368
+ // caught here. Same policy as performRowDrop()/runDeleteRow() above:
10369
+ // can't relocate it on the live table -> drop the gesture and say so,
10370
+ // never guess.
10371
+ if (afterRowIndex >= 0 && !afterRow) {
10372
+ showBanner(DROPPED_GESTURE_MESSAGE, null, null);
10373
+ return;
10374
+ }
9486
10375
  currentBurst.history.snap('insert-row-pre');
9487
10376
  insertRow(liveTableEl, afterRow);
9488
10377
  armNewTableCells(liveTableEl);
@@ -9816,9 +10705,32 @@
9816
10705
  // ~104px to ~60px, which puts the menu under the toolbar while the grip that
9817
10706
  // opens it is still comfortably clickable. Both numbers were measured on
9818
10707
  // this branch, on either side of that change.
9819
- function placeTeMenuAboveCell(r) {
9820
- const topInset = parseFloat(getComputedStyle(document.documentElement)
10708
+ /**
10709
+ * The y a viewport-anchored affordance may not go above.
10710
+ *
10711
+ * The toolbar owns the top of the viewport and it owns it at `z-index: 101`,
10712
+ * which is above every floating thing this file positions — so an affordance
10713
+ * placed at a smaller `top` is not merely close to the toolbar, it is UNDER
10714
+ * it: painted, hit-tested and clicked by the bar instead. `--ed-toolbar-h` is
10715
+ * the height the bar reserves, and it is the floor `.ed-te-menu` and
10716
+ * `.ed-seltb` already use; v3.4.0 batch 1 moved `.ed-conflict` to the same
10717
+ * value for the same reason.
10718
+ *
10719
+ * It is a FUNCTION and not a copy of the number in three places because the
10720
+ * number was copied in three places: `.ed-wave-edit-btn` floored at `4`
10721
+ * instead, and measured at 1200x800 with a 180px diagram scrolled to
10722
+ * `r.top = -60` the button painted at `top: 4`, `elementFromPoint` on its own
10723
+ * centre returned `DIV.ed-toolbar`, and a real click left the editor closed —
10724
+ * with no keyboard entry to fall back on, that scroll position had no way in
10725
+ * at all. The next affordance reads this instead of rediscovering it.
10726
+ */
10727
+ function toolbarTopInset() {
10728
+ return parseFloat(getComputedStyle(document.documentElement)
9821
10729
  .getPropertyValue('--ed-toolbar-h')) || 0;
10730
+ }
10731
+
10732
+ function placeTeMenuAboveCell(r) {
10733
+ const topInset = toolbarTopInset();
9822
10734
  let top = r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX;
9823
10735
  if (top < topInset) top = r.bottom + TE_MENU_GAP_PX;
9824
10736
  teEdgeMenu.style.top = top + 'px';
@@ -10390,21 +11302,82 @@
10390
11302
  // from table-leave, burst-end, and drag-start elsewhere) already covers
10391
11303
  // every path that actually needs to clear it.
10392
11304
  //
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'))) {
11305
+ // v3.4.0 (backlog #11, closing Ruling T14-5's deferral): the list below
11306
+ // now also names '.ed-tb-insert' — the hover-insert + bubble is the same
11307
+ // kind of `position: fixed` overlay painted over the same band as the two
11308
+ // grips, and without this the pointer standing on it fell through to the
11309
+ // branches beneath and took the ⠿ grips down (hideTableGrips() nulling
11310
+ // gripRowTableEl/gripRowEl/gripColTableEl/gripColIndex, not just hiding
11311
+ // the DOM).
11312
+ //
11313
+ // MEASURED effect on setGutterKeep(), which T14-5 left unmeasured: on a
11314
+ // 2-row/2-col fixture, hovering the header row raised the row grip at
11315
+ // rect {left:402, top:175.7, right:422, bottom:203.7}. The row-insert
11316
+ // bubble for "after the header row" rendered at {left:403, top:199.4,
11317
+ // right:421, bottom:217.4} — the bubble's OWN vertical centre (208.4)
11318
+ // already sits 0.6px below pointInRowGripZone()'s padded zone (bottom
11319
+ // 207.7 = grip bottom + the 4px pad), so a pointer landing there hit the
11320
+ // "not in zone" branch and called hideTableGrips() even before this fix.
11321
+ // Only the bubble's own top ~8px sliver (199.4-207.7) actually overlaps
11322
+ // the zone. Review round 2, M2: that 8.4px is this fixture's number, not
11323
+ // a constant — the general form (derived from the row grip's fixed
11324
+ // CSS height 28px, vertically centred on its own row per
11325
+ // buildTableGrip()/the row-grip positioning above, TE_GRIP_ZONE_PAD_PX=4,
11326
+ // and the bubble's own half-height 9px) is
11327
+ // `overlap = max(0, 27 - headerRowHeight / 2)` for any header row
11328
+ // taller than 18px (shorter than that and the whole 18px bubble sits
11329
+ // inside the zone — unreachable in practice, since padding alone
11330
+ // ('th, td { padding: 7px 14px }') already puts a real header row above
11331
+ // it). It hits 0 — no overlap at all — once the header row is >=54px
11332
+ // tall. This fixture's 37.3px header row gives 27 - 18.6 = 8.4, matching
11333
+ // the measured number above.
11334
+ //
11335
+ // The column-insert bubble does NOT overlap the row grip's
11336
+ // zone on the fixture measured above (x=[884.6, 902.6] against the
11337
+ // grip's padded x=[398,426]) — review round 2 corrected an earlier draft
11338
+ // of this comment that called that "structural", i.e. impossible in
11339
+ // general. It is not: the column bubble sits at `cellRight - half`
11340
+ // (half = TB_BUBBLE_SIZE/2 = 9), so it overlaps the row grip's own
11341
+ // padded zone (right edge at tableLeft + TE_GRIP_ZONE_PAD_PX = tableLeft
11342
+ // + 14, see pointInRowGripZone()) whenever `cellRight <= tableLeft + 23`.
11343
+ // The FIRST column is the only one close enough to matter, and its
11344
+ // MINIMUM width from `th, td { padding: 7px 14px; border: 1px solid … }`
11345
+ // alone (an empty or near-empty first cell) is ~30px — a 7px margin
11346
+ // above the 23px threshold, not a structural gap. That margin is a CSS
11347
+ // padding constant, not a geometric guarantee, and this repo's own
11348
+ // `table { overflow-x: auto }` can erode it directly: scrolling a wide
11349
+ // table's inner content moves `cellRect.right` toward (and, scrolled far
11350
+ // enough, past) `tableRect.left` while the row grip stays anchored to
11351
+ // the table's own (unscrolled) left edge, so the same overlap this
11352
+ // function already tolerates for the row-insert bubble becomes reachable
11353
+ // for the column-insert bubble too on a sufficiently scrolled table. The
11354
+ // BEHAVIOUR conclusion is unaffected either way — that path's delta is
11355
+ // still only the same cosmetic keep-lit loss described above, since
11356
+ // '.ed-tb-insert' never matches '.ed-te-grip-row' regardless of which
11357
+ // bubble the pointer is on — only the "never" in this comment was wrong.
11358
+ //
11359
+ // So the only real change is: for the narrow case where (a) the header
11360
+ // row's own grip is the one currently shown AND (b) the pointer is in
11361
+ // that ~8px sliver of the header row's insert bubble, the old fallback
11362
+ // branch below (pointInRowGripZone()/pointInColGripZone(), reached by
11363
+ // NOT matching this guard) could call setGutterKeep(headerGripBlock())
11364
+ // and light the gutter; the widened guard here calls setGutterKeep(null)
11365
+ // unconditionally instead (same as the existing column-grip branch),
11366
+ // because '.ed-tb-insert' never matches '.ed-te-grip-row'. The ⠿'s
11367
+ // keep-lit assist is not applied while the pointer sits in that sliver.
11368
+ // Accepted: it is a purely cosmetic loss (the block's own :hover keeps
11369
+ // the ⠿ visible whenever the pointer is actually over the block; keep-lit
11370
+ // only covers the pointer being OUTSIDE it) confined to an ~8px band on
11371
+ // one bubble, against hideTableGrips() tearing down all four module-level
11372
+ // refs and both grips' DOM visibility on every approach to any bubble —
11373
+ // the defect this task exists to close.
11374
+ if (target && target.closest &&
11375
+ (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col') || target.closest('.ed-tb-insert'))) {
10403
11376
  // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
10404
11377
  // 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.
11378
+ // the column grip (and, as of v3.4.0, the insert bubble) sits nowhere
11379
+ // near a position lighting the gutter should follow, and lighting it
11380
+ // from there is the behaviour change this fix is scoped to avoid.
10408
11381
  setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
10409
11382
  return;
10410
11383
  }
@@ -10715,13 +11688,47 @@
10715
11688
  // 吃掉之後沒有人會把焦點放回去,document.activeElement 會落到 <body>,
10716
11689
  // 於是 keydown 走不到 handleTableCellKeydown,Ctrl+Z 會落到全域 undo()
10717
11690
  // 而先 commit 再退。用序位重新解析目標格並真的 focus。
11691
+ // v3.4.0 §2 (measured, not guessed — see the two call sites' own comment
11692
+ // below for the full chain): by the time this runs, BOTH callers' drags
11693
+ // already have SOME cell focused — either the burst was already open
11694
+ // (the user had genuinely clicked/edited a cell themselves;
11695
+ // ensureTableBurstOpen() early-returns without calling focus() at all in
11696
+ // that case) or ensureTableBurstOpen() just opened a fresh one via its
11697
+ // own `forDrag` branch (which DOES call focus(), on the first body
11698
+ // cell). Either way, a plain focus() here was re-focusing that SAME cell
11699
+ // a second time — a stack-traced probe caught exactly that: two focus()
11700
+ // calls on the identical element, the second one (this one) with no
11701
+ // options, immediately followed by the page scrolling. `preventScroll`
11702
+ // stops that second, redundant scroll without changing which cell ends
11703
+ // up focused.
11704
+ // v3.4.0 §2: `cellIndex < 0`'s fallback used to be `cells[0]` — the
11705
+ // header cell — inherited from before this function had a `forDrag`-style
11706
+ // "avoid the header" policy anywhere. It stayed reachable in theory (a
11707
+ // negative index means "no cell to re-resolve"), but the row/col-drop
11708
+ // stale-index defect fixed below (both call sites now re-resolve against
11709
+ // a rebuild-stable coordinate rather than a cell reference/ordinal that
11710
+ // may have moved) removes every realistic path that could still produce
11711
+ // one here: a drag never deletes the active row/cell, only reorders it,
11712
+ // so a valid `activeCellEl` captured before the drop always resolves to
11713
+ // SOME live cell afterward. Changed anyway, defensively, since both of
11714
+ // this function's only two callers are drag paths (same fact the
11715
+ // `forDrag` policy above is built on): prefer the first BODY cell over
11716
+ // the header, same as `ensureTableBurstOpen()`'s own `forDrag` pick — a
11717
+ // drag landing on the header is exactly the symptom this whole task
11718
+ // fixed, so a fallback that still could is worth closing even if nothing
11719
+ // currently reaches it. Falls through to `cells[0]` only when the table
11720
+ // has no body row at all; `if (!cell) return` below still means "don't
11721
+ // focus anything" for the fully-empty case, covering that alternative
11722
+ // too.
10718
11723
  function restoreTableFocus(tableEl, cellIndex) {
10719
11724
  const cells = tableCellsOf(tableEl);
10720
- const cell = cells[cellIndex >= 0 ? Math.min(cellIndex, cells.length - 1) : 0];
11725
+ const cell = cellIndex >= 0
11726
+ ? cells[Math.min(cellIndex, cells.length - 1)]
11727
+ : (cells.find((c) => c.tagName !== 'TH') || cells[0]);
10721
11728
  if (!cell) return;
10722
11729
  if (currentBurst && currentBurst.blockType === 'table') currentBurst.activeCellEl = cell;
10723
11730
  selToolbarEditEl = cell;
10724
- cell.focus();
11731
+ cell.focus({ preventScroll: true });
10725
11732
  placeCaretAtEnd(cell);
10726
11733
  }
10727
11734
 
@@ -10730,7 +11737,11 @@
10730
11737
  // 可能 resolve 掉別的 block 的 dirty burst 並換掉整片 .content,所以
10731
11738
  // 先轉成 allRowsOf() 的 ordinal,之後在 live table 上重新定位。
10732
11739
  const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
10733
- const liveTableEl = await ensureTableBurstOpen(tableEl);
11740
+ // v3.4.0 §2: `forDrag=true` — see ensureTableBurstOpen()'s own comment.
11741
+ // A row drag never had a focused cell of its own, so if this call is
11742
+ // the one that opens the burst, it must neither scroll the page nor
11743
+ // land on the header cell.
11744
+ const liveTableEl = await ensureTableBurstOpen(tableEl, true);
10734
11745
  if (!liveTableEl) return;
10735
11746
  const liveRows = allRowsOf(liveTableEl);
10736
11747
  if (rowIndex < 0 || !liveRows[rowIndex]) return;
@@ -10747,8 +11758,55 @@
10747
11758
 
10748
11759
  currentBurst.history.snap('drag-row-pre');
10749
11760
  const aligns = columnAlignsOf(liveTableEl);
10750
- const activeIndex = (currentBurst && currentBurst.activeCellEl)
10751
- ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
11761
+ // v3.4.0 §2(量出來的,不是猜的):這裡曾經寫過
11762
+ // `if (activeIndex >= 0) restoreTableFocus(...)`,想法是拖曳沒點過任何
11763
+ // 儲存格時 activeIndex 應該是 -1、藉此跳過這次呼叫。實測證明那個模型
11764
+ // 錯了——`activeIndex` 在這裡結構上永遠 >= 0:ensureTableBurstOpen()
11765
+ // 只有在 currentBurst 確實是這個表格時才會回傳非 null,而
11766
+ // `currentBurst.activeCellEl` 在 startTableBurst() 建立 currentBurst
11767
+ // 的同一個賦值式裡就一定被設好(不管 burst 是本來就開著、還是這次
11768
+ // ensureTableBurstOpen() 的 forDrag 分支剛開的)。所以「用 index 判斷
11769
+ // 要不要呼叫 restoreTableFocus()」這條路走不通,不要再試。
11770
+ //
11771
+ // 真正的捲動來源是 restoreTableFocus() 自己那次 focus():它一定會把
11772
+ // ensureTableBurstOpen() 剛剛(forDrag、preventScroll、挑 body 儲存格)
11773
+ // 已經對好焦的那顆儲存格,重新 focus 一次——用一支外接的 stack-trace
11774
+ // 探針量到兩次 focus() 打在同一顆元素上,第二次沒帶 preventScroll,
11775
+ // 緊接著就是那次把畫面拉回去的捲動。修法落在 restoreTableFocus() 自己
11776
+ // 身上(它的 cell.focus() 現在帶 { preventScroll: true }),這裡維持
11777
+ // 無條件呼叫。
11778
+ //
11779
+ // 另一個既存缺陷(跟以上兩件事無關,重排「之前」量、「之後」用同一個
11780
+ // 序位):原本 `activeIndex` 在這裡(rebuildTableSections() 之前)用
11781
+ // `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算好,
11782
+ // 卻在下面 restoreTableFocus() 裡對「重排之後」的 cell 清單使用——重排
11783
+ // 只要讓被拖那列插到 activeCellEl 那列前面,中間隔的 cell 數就變了,
11784
+ // 舊序位對到的會是別的儲存格(使用者正在打字那格,焦點卻被丟到別的
11785
+ // TD,接下來每一鍵都寫進錯的儲存格)。用一支 stack-trace 探針量過:
11786
+ // rebuildTableSections() 本身只搬 <tr>(appendChild,never 重建),但
11787
+ // retagCell() 在該列跨越表頭/本文邊界(被升格成表頭或被降成本文)時
11788
+ // **會**造出全新的 <td>/<th>(複製屬性、不是同一顆物件)——用 JS
11789
+ // expando 屬性(不是 HTML attribute,因為 attribute 會被複製過去,測
11790
+ // 不出是不是同一顆)量過:不跨表頭時標記存活,跨表頭時標記消失。
11791
+ // <tr> 元素本身兩種情況都不變(只有 appendChild 搬過),所以「列+欄」
11792
+ // 這組座標在重排前後都穩,序位不穩。改成:重排前記下 activeRowEl(那
11793
+ // 顆 <tr>)與 activeColIdx(欄序,colIndexOf() 已有);重排、
11794
+ // armNewTableCells() 都跑完之後,用 `activeRowEl.cells[activeColIdx]`
11795
+ // 重新取得那顆儲存格(不管 retagCell() 換過沒有,同一列同一欄序就是
11796
+ // 使用者原本在打字的那顆),再轉回 restoreTableFocus() 要的序位。
11797
+ // Review fix (M2): `document.body.contains()` first — same guard
11798
+ // refocusAwayFromColumn()/refocusAwayFromRow() above both use before
11799
+ // their own colIndexOf() call. `activeCellEl` DETACHED (not merely
11800
+ // stale) is a state this file already treats as reachable there;
11801
+ // colIndexOf() reads `cellEl.parentElement.cells`, which throws on a
11802
+ // detached node whose parentElement is null — unlike the old
11803
+ // `tableCellsOf(...).indexOf(...)` this replaced, which just returned
11804
+ // -1 for a detached cell. Thrown here (after `history.snap('drag-row-pre')`,
11805
+ // before the reorder) would silently drop the whole gesture.
11806
+ const activeCellBefore = (currentBurst && currentBurst.activeCellEl &&
11807
+ document.body.contains(currentBurst.activeCellEl)) ? currentBurst.activeCellEl : null;
11808
+ const activeRowEl = activeCellBefore ? activeCellBefore.parentElement : null;
11809
+ const activeColIdx = activeCellBefore ? colIndexOf(activeCellBefore) : -1;
10752
11810
  suppressTableFocusout = true;
10753
11811
  try {
10754
11812
  rebuildTableSections(liveTableEl, order, aligns);
@@ -10756,6 +11814,8 @@
10756
11814
  suppressTableFocusout = false;
10757
11815
  }
10758
11816
  armNewTableCells(liveTableEl);
11817
+ const restoredCell = (activeRowEl && activeColIdx >= 0) ? activeRowEl.cells[activeColIdx] : null;
11818
+ const activeIndex = restoredCell ? tableCellsOf(liveTableEl).indexOf(restoredCell) : -1;
10759
11819
  restoreTableFocus(liveTableEl, activeIndex);
10760
11820
  // 插入路徑早就這樣做了,drop 路徑一直沒有:不清的話 grip 還釘在舊
10761
11821
  // 座標、teMenuColIndex 指向已經換位的欄。
@@ -10792,11 +11852,10 @@
10792
11852
  }
10793
11853
 
10794
11854
  async function performColDrop(tableEl, fromIndex, toIndex) {
10795
- const liveTableEl = await ensureTableBurstOpen(tableEl);
11855
+ // v3.4.0 §2: `forDrag=true` — same reasoning as performRowDrop() above.
11856
+ const liveTableEl = await ensureTableBurstOpen(tableEl, true);
10796
11857
  if (!liveTableEl) return;
10797
11858
  if (toIndex === fromIndex || toIndex === fromIndex + 1) return; // 原地放回
10798
- const activeIndex = (currentBurst && currentBurst.activeCellEl)
10799
- ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
10800
11859
  // 短列必須讓整個操作放棄,不能只跳過那一列(final review M4):原本
10801
11860
  // `if (!moved) return;` 在 forEach 裡面,短列會被略過、其他列照搬 —— 結果
10802
11861
  // 是欄位彼此錯位,而每一列的 cell 數量都跟原本一樣,ragged-table guard
@@ -10816,6 +11875,27 @@
10816
11875
  } finally {
10817
11876
  suppressTableFocusout = false;
10818
11877
  }
11878
+ // v3.4.0 §2(量出來的,不是猜的——完整說明見 performRowDrop() 對應處的
11879
+ // 同一段註解):這裡曾經寫過 `if (activeIndex >= 0) restoreTableFocus(...)`,
11880
+ // 想靠 index 擋掉這次呼叫,但 `activeIndex` 在這裡結構上永遠 >= 0
11881
+ // (ensureTableBurstOpen() 只有在 currentBurst 確實是這個表格時才回傳
11882
+ // 非 null,而 currentBurst.activeCellEl 在 startTableBurst() 建立
11883
+ // currentBurst 的同一個賦值式裡就一定被設好)——這條路走不通,不要
11884
+ // 再試。真正的捲動來源是 restoreTableFocus() 自己那次 focus(),修法
11885
+ // 落在它自己身上(`{ preventScroll: true }`),這裡維持無條件呼叫。
11886
+ //
11887
+ // 另一個既存缺陷,跟列拖曳同根、但失效方式不同(欄重排動的是「同一列
11888
+ // 裡 cell 的順序」,不是列本身):這裡原本也是在上面重排「之前」用
11889
+ // `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算
11890
+ // `activeIndex`,重排之後才用——欄一旦搬過,每一列裡 cell 的順序都變
11891
+ // 了,`tableCellsOf()`(跨全表攤平)算出來的序位一樣是舊的。用同一支
11892
+ // 探針量過:欄重排的 forEach 是 `row.appendChild(c)`,對已經是那個
11893
+ // row 子節點的 cell 只是搬動、從不建立新節點(跟列拖曳的 retagCell()
11894
+ // 不同,欄拖曳完全不牽涉 TH/TD 轉換),所以不需要「列+欄」那組座標
11895
+ // ——重排後直接用同一顆 `currentBurst.activeCellEl` 物件重算一次
11896
+ // indexOf() 就對了,物件本身沒被換過。
11897
+ const activeIndex = (currentBurst && currentBurst.activeCellEl)
11898
+ ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
10819
11899
  restoreTableFocus(liveTableEl, activeIndex);
10820
11900
  hideTableGrips();
10821
11901
  hideTableEdgeMenu();
@@ -11661,6 +12741,12 @@
11661
12741
  // next one.
11662
12742
  blockSelClickSuppressed = false;
11663
12743
  if (blockSelDrag) endBlockSelDrag();
12744
+ // Every defensive teardown above still runs — they are no-ops that must not
12745
+ // be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
12746
+ // already makes this unreachable (measured: a Shift+press-drag on the canvas
12747
+ // and a press-drag on the backdrop both leave the selection null); it is
12748
+ // here so that stays true if the backdrop ever stops being `inset: 0`.
12749
+ if (waveEditorIsModal()) return;
11664
12750
  const hit = hitTestGrip(e.target);
11665
12751
  // A click on a DIFFERENT zone (or entirely outside any zone) dismisses
11666
12752
  // whatever menu is already open, same "any other click closes the ⠿
@@ -12440,6 +13526,15 @@
12440
13526
  snapBurstIfActive(root, 'mark');
12441
13527
  return false; // unwrapped in place — no modal ever opened
12442
13528
  }
13529
+ // v3.4.0 / v3.3.0 backlog 4: applyMarkToggle() guards its wrap branch with
13530
+ // trimRangeToText() before wrapping; this new-link branch never had the
13531
+ // matching guard. A whitespace-only selection (the space between two
13532
+ // words, say) reached window.prompt() and, on accept, extractRangeInto()
13533
+ // wrapped that bare space in an <a> — an anchor whose entire content was
13534
+ // one space, written straight to disk. Trim first: an all-whitespace
13535
+ // range trims to nothing and trimRangeToText() returns false, so bail
13536
+ // before ever opening the prompt.
13537
+ if (!trimRangeToText(range)) return false;
12443
13538
  const url = window.prompt('連結網址:', 'https://');
12444
13539
  if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
12445
13540
  snapBurstIfActive(root, 'mark-pre');
@@ -12539,8 +13634,7 @@
12539
13634
  const rect = range.getBoundingClientRect();
12540
13635
  // v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
12541
13636
  // 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
12542
- const topInset = parseFloat(getComputedStyle(document.documentElement)
12543
- .getPropertyValue('--ed-toolbar-h')) || 0;
13637
+ const topInset = toolbarTopInset();
12544
13638
  if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
12545
13639
  rect.right <= 0 || rect.left >= window.innerWidth) {
12546
13640
  // Task 16: of the exits that take this bar down, this is the one that
@@ -12573,8 +13667,25 @@
12573
13667
  // the raw textarea directly on click, no bar/menu step) — see
12574
13668
  // armEditables() above. What's left to route here is: the lightbox
12575
13669
  // exclusion, the ⠿ handle/menu, and opening a degraded block's raw editor.
13670
+ // Fix round 3 (re-review2 Q2): `.drawio` belongs here for the same reason
13671
+ // every other entry does — it is a lightbox target in the reader runtime
13672
+ // (`LIGHTBOX_TARGETS` in lib/md2doc.js), and a click that opens the lightbox
13673
+ // must not ALSO be routed as a click on the block.
13674
+ //
13675
+ // MEASURED before adding it, in a real editor page with a diagram in an
13676
+ // armed paragraph and the same diagram in a DEGRADED table:
13677
+ // paragraph lightbox flex/visible, `.ed-raw` count 0, activeElement BODY
13678
+ // (the armed branch already returns above — no consequence)
13679
+ // table lightbox flex/visible, `.ed-raw` count 1,
13680
+ // activeElement TEXTAREA.ed-raw
13681
+ // i.e. on a degraded block the raw markdown editor silently opened BEHIND
13682
+ // the lightbox. With `.drawio` listed, the diagram behaves exactly like the
13683
+ // `img` / `.mermaid` / `.graphviz` / WaveDrom entries beside it: clicking
13684
+ // the picture opens the picture and nothing else. No capability is lost
13685
+ // that those four do not already lack — the block's source is still one
13686
+ // click away anywhere else in the block.
12576
13687
  const ED_LIGHTBOX_TARGETS =
12577
- 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
13688
+ 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
12578
13689
 
12579
13690
  // Single delegated listener, wired once at the bottom of this file. Async
12580
13691
  // because clicking another block (or outside any block) while some
@@ -12687,6 +13798,14 @@
12687
13798
  // would also fall through to "clicked outside any block" below and
12688
13799
  // fire an unwanted switchAwayFrom() right after the menu just opened.
12689
13800
  if (e.target.closest('.ed-te-grip')) return;
13801
+ // v3.4.0 batch3 Task 6: while the wave editor is the modal surface,
13802
+ // nothing behind it takes clicks. This replaced a pair of `closest()`
13803
+ // exclusions for the overlay and its entry button: those were right about
13804
+ // the two cases they named — a click on either used to fall through to
13805
+ // "clicked outside any block" and fire switchAwayFrom() on the document
13806
+ // behind the modal — and wrong about being a list at all.
13807
+ if (waveEditorIsModal()) return;
13808
+ if (e.target.closest('.ed-wave-edit-btn')) return;
12690
13809
  // Task 9: task-list checkbox toggle. The .ed-li-check span is
12691
13810
  // non-focusable chrome — no per-node listener, routed here by
12692
13811
  // delegation. Gate BEFORE mutation (same contract as the structural
@@ -12828,6 +13947,39 @@
12828
13947
 
12829
13948
  // ── save ───────────────────────────────────────────────────────────────
12830
13949
  async function save() {
13950
+ // fix 3: the receipt for the bytes about to leave, taken BEFORE the request
13951
+ // and not after it. `markSaved()` used to read the stack depth when the
13952
+ // RESPONSE landed, and a save round trip is hundreds of milliseconds —
13953
+ // easily long enough to press Ctrl+Z in. MEASURED: type, commit, Ctrl+S,
13954
+ // Ctrl+Z inside the round trip — the 200 marked the post-undo depth as the
13955
+ // saved point, so the title dot went out, the save button greyed,
13956
+ // `beforeunload` stopped firing, AND `mtimeMs` had just advanced so the
13957
+ // conflict check would not catch it either. All three nets failed at once,
13958
+ // and the tab closed leaving on disk an edit the user had explicitly undone.
13959
+ // See UndoStack.saveToken()/markSaved() for why the receipt carries an op
13960
+ // identity and not just a depth.
13961
+ const token = stack.saveToken();
13962
+ //
13963
+ // ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
13964
+ // that is a correction of something this file briefly carried. The premise
13965
+ // for dropping an "older" reply was that a later request's bytes are what
13966
+ // disk ends up holding. That premise is FALSE: the server 409s whenever
13967
+ // `baseMtimeMs` does not match the file (server.js's /api/save), and
13968
+ // `mtimeMs` has exactly one writer — the 200 branch below. So a second save
13969
+ // sent while the first is still in flight necessarily carries a stale
13970
+ // baseline, necessarily 409s, and necessarily writes nothing. The FIRST
13971
+ // reply is the one that describes what is on disk, and dropping it is the
13972
+ // defect, not the fix. MEASURED with the guard in place: type, Ctrl+S,
13973
+ // Ctrl+Z, Ctrl+S — the first 200 was discarded, `markSaved()` never ran, and
13974
+ // the document read CLEAN with the edit still on disk; and without any undo
13975
+ // at all, two Ctrl+S in a row left `mtimeMs` stale forever, so every later
13976
+ // save 409'd and the only way out was the conflict banner's reload. An
13977
+ // exhaustive depth-6 sweep counted 48 settled under-warns with the guard and
13978
+ // 0 without it.
13979
+ //
13980
+ // The receipt above is what makes a late reply safe without any of that: it
13981
+ // describes the bytes it was taken for, so a reply that no longer matches
13982
+ // the timeline refuses itself.
12831
13983
  let res;
12832
13984
  try {
12833
13985
  res = await fetch('/api/save', {
@@ -12848,8 +14000,17 @@
12848
14000
  return;
12849
14001
  }
12850
14002
  mtimeMs = j.mtimeMs;
12851
- stack.markSaved();
14003
+ stack.markSaved(token);
12852
14004
  setDirty();
14005
+ // v3.4.0 §3: markSaved() just moved documentIsDirty() from true to
14006
+ // false, and the `save` button's own lit/grey state is read off
14007
+ // exactly that value (toolbarContext()'s `dirty` field) — without this
14008
+ // the button stays lit after a successful save until some UNRELATED
14009
+ // gesture happens to repaint the bar. Same repaint entry point
14010
+ // setDirty()'s own callers already share (updateToolbar() diffs its
14011
+ // own signature, so this is cheap on the common "toolbar was already
14012
+ // caught up" case).
14013
+ updateToolbar();
12853
14014
  return;
12854
14015
  }
12855
14016
  if (res.status === 409) {
@@ -12863,6 +14024,140 @@
12863
14024
  showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
12864
14025
  }
12865
14026
 
14027
+ // v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
14028
+ // Ctrl+S's own keydown handler already carries the full explanation (a
14029
+ // mid-burst keystroke has not reached `lines` yet, so save() must not run
14030
+ // before switchAwayFrom() does). The toolbar `save` button needs the
14031
+ // identical precondition, so both callers share this one function instead
14032
+ // of each spelling out `switchAwayFrom().then(...)` on its own — not a
14033
+ // copy of save()'s own body, just its one caller-side precondition named
14034
+ // once.
14035
+ async function commitThenSave() {
14036
+ const ok = await switchAwayFrom();
14037
+ if (ok) return save();
14038
+ }
14039
+
14040
+ // v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
14041
+ // may resolve (commit + re-render) a still-open burst before save() ever
14042
+ // runs, and that render is exactly the class of render that strands the
14043
+ // caret on BODY when the caret was sitting inside the block being
14044
+ // replaced — recorded, for undo/redo/image, in activateToolbarCursor()'s
14045
+ // own comment ("join them ONLY when the surface has an uncommitted
14046
+ // edit"), and unconditionally for quote/code/line (backlog #6, still
14047
+ // open). `save` must not become a fourth name on that list — and
14048
+ // deliberately does NOT reuse undo()/redo()'s own path to avoid it:
14049
+ // neither of them has any rescue at all (that gap is recorded as
14050
+ // deferred, not fixed), so there is nothing there worth copying.
14051
+ //
14052
+ // What captureCaretForSave() saves: which `.ed-block` the caret's FOCUS
14053
+ // end was in (`data-block-id` — stable across a same-shape commit, since
14054
+ // buildBlockMap() numbers every block by document order starting at 0 on
14055
+ // every render, and a text-only edit that neither adds nor removes a
14056
+ // block leaves every block's id exactly where it was) and how many
14057
+ // characters into that block's own rendered text that end sat (every text
14058
+ // node before it, concatenated — the same measure
14059
+ // `Range.toString().length` gives over a selectNodeContents() range
14060
+ // clipped to that point). restoreCaretForSave() reverses it: walk the
14061
+ // (possibly brand-new) block's text nodes until that many characters are
14062
+ // consumed, and collapse a Range there. It is a no-op whenever the caret
14063
+ // was never stranded in the first place (the common case — a clean
14064
+ // document, or a burst with nothing uncommitted, never touches the DOM at
14065
+ // all) — checked by asking whether focus is still on BODY afterwards, not
14066
+ // by trying to predict which of the paths above ran.
14067
+ //
14068
+ // Review C1: `sel.focusNode`/`focusOffset`, not `anchorNode`/
14069
+ // `anchorOffset`, and no `!sel.isCollapsed` bail-out. The first version
14070
+ // gave up on any non-collapsed selection entirely — MEASURED: type at the
14071
+ // end of a block without committing, extend the selection left with
14072
+ // Shift+ArrowLeft, click save — restoreCaretForSave() never ran at all
14073
+ // (captureCaretForSave() had already returned null), and the caret was
14074
+ // left exactly where quote/code/line leave it: on BODY, typing silently
14075
+ // discarded. That is backlog #6's own symptom, reached through a keyboard
14076
+ // path that is not remotely rare (fix a typo with Shift+Arrow, then Ctrl+
14077
+ // S/Alt+F10+Enter on `save`). A collapsed caret is only the special case
14078
+ // where anchor and focus already coincide; using focus and dropping the
14079
+ // guard restores BOTH cases through one path. Collapsing the RESTORED
14080
+ // caret at that single point (rather than reconstructing the whole
14081
+ // pre-click range) is deliberate — landing at the focus end, one keyboard
14082
+ // Shift+Arrow removed from BODY rather than on it, is what backlog #6
14083
+ // asks for; there is no saved anchor position left to rebuild a
14084
+ // non-collapsed selection FROM once the block has been re-rendered.
14085
+ function captureCaretForSave() {
14086
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
14087
+ if (!sel || sel.rangeCount === 0) return null;
14088
+ const focus = sel.focusNode;
14089
+ if (!focus) return null;
14090
+ const host = focus.nodeType === 1 ? focus : focus.parentElement;
14091
+ const blockEl = host && host.closest ? host.closest('.ed-block') : null;
14092
+ if (!blockEl) return null;
14093
+ const blockId = blockEl.getAttribute('data-block-id');
14094
+ if (blockId === null) return null;
14095
+ const measure = document.createRange();
14096
+ measure.selectNodeContents(blockEl);
14097
+ measure.setEnd(focus, sel.focusOffset);
14098
+ return { blockId: blockId, offset: measure.toString().length };
14099
+ }
14100
+
14101
+ function restoreCaretForSave(saved) {
14102
+ // Nothing to do when the caret was never stranded — leave whatever is
14103
+ // focused (including a genuinely different block the user clicked into
14104
+ // meanwhile) alone.
14105
+ if (!saved || document.activeElement !== document.body) return;
14106
+ const blockEl = contentEl.querySelector(
14107
+ '.ed-block[data-block-id="' + saved.blockId + '"]');
14108
+ // Review M1: `blockEl` is null whenever the commit added or removed
14109
+ // blocks (buildBlockMap() renumbers by document order, so `save`'s own
14110
+ // commit — a plain text edit — cannot do this, but a future caller of
14111
+ // captureCaretForSave()/restoreCaretForSave() might run them around
14112
+ // something structural). There is no corresponding block left to place
14113
+ // a caret in at all, so this silently leaves the caret on BODY — a
14114
+ // strict improvement over guessing at a different block, but not a full
14115
+ // rescue. Falling back to `document.body.focus()` would not help either
14116
+ // (BODY is not focusable), so this is left as a documented, narrower
14117
+ // gap rather than a fixed one.
14118
+ const surface = blockEl && blockContentEl(blockEl);
14119
+ if (!surface) return;
14120
+ const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
14121
+ let remaining = saved.offset;
14122
+ let node = walker.nextNode();
14123
+ let target = null;
14124
+ let targetOffset = 0;
14125
+ while (node) {
14126
+ if (remaining <= node.nodeValue.length) { target = node; targetOffset = remaining; break; }
14127
+ remaining -= node.nodeValue.length;
14128
+ node = walker.nextNode();
14129
+ }
14130
+ if (!target) { placeCaretAtEnd(surface); return; }
14131
+ const range = document.createRange();
14132
+ range.setStart(target, targetOffset);
14133
+ range.collapse(true);
14134
+ const sel = window.getSelection();
14135
+ sel.removeAllRanges();
14136
+ sel.addRange(range);
14137
+ }
14138
+
14139
+ // The one entry point the toolbar/keyboard-cursor dispatch calls — wraps
14140
+ // commitThenSave() with the capture/restore pair above. Ctrl+S itself is
14141
+ // untouched: it is not one of the three buttons backlog #6 names, driving it
14142
+ // was out of that task's scope, and wrapping it here too would risk
14143
+ // perturbing the Finding-1/Finding-3 Ctrl+S coverage this branch already has
14144
+ // for reasons that have nothing to do with the caret.
14145
+ //
14146
+ // fix 5 / R9: the sentence here used to say Ctrl+S "still calls
14147
+ // commitThenSave() directly". It does not and has not — the keydown branch
14148
+ // spells the same precondition out inline as
14149
+ // `switchAwayFrom().then((ok) => { if (ok) save(); })`, deliberately, because
14150
+ // that exact line is pinned verbatim by shortcutOrderChecks in
14151
+ // test/editor-client.test.js (its own comment there says so). The two
14152
+ // spellings are behaviourally identical; the claim about which function is
14153
+ // called was simply wrong, and a reader following it would go looking for a
14154
+ // call site that does not exist.
14155
+ async function saveViaToolbar() {
14156
+ const saved = captureCaretForSave();
14157
+ await commitThenSave();
14158
+ restoreCaretForSave(saved);
14159
+ }
14160
+
12866
14161
  // ── undo / redo ───────────────────────────────────────────────────────
12867
14162
  async function undo() {
12868
14163
  // T21 item 1: an Escape that discarded a burst leaves exactly one thing
@@ -12871,6 +14166,16 @@
12871
14166
  // answers false whenever there is no such thing (which is almost always),
12872
14167
  // and the ordinary document undo below runs untouched.
12873
14168
  if (restoreDiscardedBurst()) return;
14169
+ // v3.4.0 (Task 7 item 5): the raw-editor twin — see restoreDiscardedRawEdit()'s
14170
+ // own comment. Mutually exclusive with the burst stash above (see
14171
+ // discardedRawEdit's own comment), so checking both in sequence never
14172
+ // answers a stale one while the other substrate holds the fresher discard.
14173
+ if (await restoreDiscardedRawEdit()) return;
14174
+ // v3.4.0 batch3 Task 7: the wave twin. Third in the sequence for no reason
14175
+ // other than that it is the newest; all three are mutually exclusive by
14176
+ // construction (one editing session at a time) and each nulls its own slot
14177
+ // before deciding, so order cannot make a stale one answer.
14178
+ if (await restoreDiscardedWaveEdit()) return;
12874
14179
  // Resolve any open editor BEFORE the .content-replacing swap below —
12875
14180
  // see the `activeEditor` / switchAwayFrom() comments near its
12876
14181
  // declaration for why this is required (undo/redo silently detaching an
@@ -12944,12 +14249,44 @@
12944
14249
  }
12945
14250
  }
12946
14251
 
14252
+ // backlog #6 — the toolbar's own caret rescue for undo/redo/image, over
14253
+ // the SAME captureCaretForSave()/restoreCaretForSave() pair saveViaToolbar()
14254
+ // above uses, not a copy of it. activateToolbarCursor()'s own landing-note
14255
+ // comment records exactly when this bites: "undo, redo and image join
14256
+ // [quote/code/line, permanently stranded] ONLY when the surface has an
14257
+ // uncommitted edit — the action resolves that burst first, and the
14258
+ // re-render is what drops the caret." That resolve-then-render shape is
14259
+ // undo()/redo()'s own switchAwayFrom() call and pickAndInsertImage()'s own
14260
+ // — identical to what saveViaToolbar() wraps commitThenSave() around — so
14261
+ // the same capture-before/restore-after pair applies unchanged: capture
14262
+ // reads the CURRENT (possibly mid-edit) DOM before anything commits,
14263
+ // restore is a no-op whenever focus never left the DOM in the first place
14264
+ // (the common case, nothing dirty).
14265
+ //
14266
+ // Scoped to the TOOLBAR path ONLY, deliberately: Ctrl+Z/Ctrl+Y (the global
14267
+ // keydown handler further down) still call undo()/redo() directly, and a
14268
+ // focused burst's own Ctrl+Z is `burstUndo()` — an entirely different,
14269
+ // intra-burst mechanism that never reaches this file's undo()/redo() at
14270
+ // all. Widening either of those was out of this task's scope, the same
14271
+ // boundary saveViaToolbar()'s own comment draws around Ctrl+S.
14272
+ async function undoViaToolbar() {
14273
+ const saved = captureCaretForSave();
14274
+ await undo();
14275
+ restoreCaretForSave(saved);
14276
+ }
14277
+
14278
+ async function redoViaToolbar() {
14279
+ const saved = captureCaretForSave();
14280
+ await redo();
14281
+ restoreCaretForSave(saved);
14282
+ }
14283
+
12947
14284
  // ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
12948
14285
  // The version's headline feature: before this there was no toolbar at all,
12949
14286
  // and every editing affordance was hidden behind a hover gutter, a caret
12950
14287
  // position or a text selection. window.md2docToolbarModel (injected by
12951
14288
  // 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
14289
+ // 23-button roster, the group order that decides separator placement, and
12953
14290
  // the whole active/disabled derivation; everything below is mounting and
12954
14291
  // wiring. Nothing here re-derives a button's state by hand — a second
12955
14292
  // opinion about when ⇥ is legal is exactly what the pure-module split
@@ -12974,8 +14311,36 @@
12974
14311
  // intent has to be captured on the keystroke that produced it.
12975
14312
  let plainPasteArmed = false;
12976
14313
 
12977
- const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
14314
+ const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
14315
+ '只接受 PNG / JPEG / GIF / WebP 圖片,或 draw.io 的 .drawio / .xml';
14316
+ // The one place the two accepted diagram extensions are written down on
14317
+ // this side. /api/asset owns the same pair plus the content check.
14318
+ const DRAWIO_NAME_RE = /\.(drawio|xml)$/i;
14319
+
14320
+ /**
14321
+ * Is this File something the insert path will even try to send?
14322
+ *
14323
+ * ONE predicate with three call sites -- the `drop` filter,
14324
+ * `clipboardItemsOf()` and `uploadImageBlob()` -- because three copies of
14325
+ * "is this insertable" is three chances to disagree. v3.4.1's first cut
14326
+ * widened only two of them and the `drop` filter kept dropping .drawio
14327
+ * files on the floor: the filter runs BEFORE insertImages(), so the file
14328
+ * never entered the pipeline and there was no banner, no request, no
14329
+ * failure -- nothing at all happened, which is indistinguishable from the
14330
+ * feature not existing.
14331
+ *
14332
+ * "Will try to send" is the whole claim. Whether anything is WRITTEN is
14333
+ * decided by /api/asset, which re-checks the extension against its own two
14334
+ * literals and then parses the bytes.
14335
+ */
14336
+ function insertableFile(f) {
14337
+ if (!f || !assetLib) return false;
14338
+ return !!(assetLib.extFor(f.type) || DRAWIO_NAME_RE.test(f.name || ''));
14339
+ }
12978
14340
  const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
14341
+ // backlog #10 — see the keydown handler's own comment for why every
14342
+ // stray-key hand-back raises this rather than only some of them.
14343
+ const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
12979
14344
 
12980
14345
  // Extra visual classes for the three inline buttons whose own glyph is the
12981
14346
  // affordance (B is bold, I is italic, S is struck through) — the same
@@ -13155,6 +14520,11 @@
13155
14520
  // .ed-seltb is painted from the same measurement — see updateToolbar().
13156
14521
  marks: selectionMarkStates(sel),
13157
14522
  mode: docMode,
14523
+ // v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
14524
+ // above burstHasUncommittedEdit()'s own comment) is the SAME
14525
+ // expression setDirty() paints the title dot from — not a second copy
14526
+ // of it.
14527
+ dirty: documentIsDirty(),
13158
14528
  };
13159
14529
  }
13160
14530
 
@@ -13170,7 +14540,7 @@
13170
14540
  paintSelToolbarMarks(ctx.marks);
13171
14541
  const state = toolbarModel.deriveState(ctx);
13172
14542
  // selectionchange fires on every caret move, so the state is diffed
13173
- // before any DOM is written — 22 attribute writes per keystroke is a
14543
+ // before any DOM is written — 23 attribute writes per keystroke is a
13174
14544
  // cost with no matching benefit.
13175
14545
  const sig = JSON.stringify(state);
13176
14546
  if (sig === toolbarStateSig) return;
@@ -13181,7 +14551,7 @@
13181
14551
  if (!st || !btn) return;
13182
14552
  btn.disabled = !!st.disabled;
13183
14553
  // 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`
14554
+ // Writing it to all 23 made a screen reader announce `undo`, `image`
13185
14555
  // and `table` as toggle buttons stuck in the "not pressed" state,
13186
14556
  // which is a lie about what they do.
13187
14557
  if (TOOLBAR_TOGGLE_IDS.has(id)) {
@@ -13225,11 +14595,15 @@
13225
14595
  // The one-pixel margin is a deliberate cushion and NOT a measured
13226
14596
  // requirement — say so rather than inventing a reason for it. Driven at 900
13227
14597
  // 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.
14598
+ // to scrollWidth minus clientWidth to the pixel (42 and 522 — re-measured
14599
+ // for v3.4.0 §3, a one-off puppeteer script, not assumed: .ed-toolbar's gap
14600
+ // 6px -> 3px changed scrollWidth at EVERY width, not just the 420px one a
14601
+ // different scenario already re-pinned, so 900px needed its own fresh
14602
+ // number rather than inheriting the old 75), so a strict comparison would
14603
+ // have been correct at both. The cushion is there because scrollWidth is
14604
+ // reported as a whole number over a row whose own width is not, and what it
14605
+ // costs when the two agree is that the gradient goes out a pixel before the
14606
+ // travel ends.
13233
14607
  function paintToolbarOverflow() {
13234
14608
  if (!toolbarEl) return;
13235
14609
  const max = toolbarEl.scrollWidth - toolbarEl.clientWidth;
@@ -13335,6 +14709,28 @@
13335
14709
  }
13336
14710
  }
13337
14711
 
14712
+ // backlog #10: the ARIA toolbar pattern's own "jump to the edge" keys —
14713
+ // Home to the first enabled button, End to the last. `edge` is -1 for
14714
+ // Home, +1 for End. Unlike moveToolbarCursor()'s relative step, this walks
14715
+ // from the bar's own edge inward rather than from wherever the cursor
14716
+ // currently sits, so it always lands on the true first/last enabled
14717
+ // button regardless of where the cursor started.
14718
+ function moveToolbarCursorToEdge(edge) {
14719
+ const btns = toolbarRovingBtns();
14720
+ if (!btns.length) return;
14721
+ const step = edge < 0 ? 1 : -1;
14722
+ let i = edge < 0 ? 0 : btns.length - 1;
14723
+ for (let count = 0; count < btns.length; count++) {
14724
+ if (!btns[i].disabled) {
14725
+ toolbarRovingIndex = i;
14726
+ paintToolbarRoving();
14727
+ revealToolbarCursor(btns[i]);
14728
+ return;
14729
+ }
14730
+ i += step;
14731
+ }
14732
+ }
14733
+
13338
14734
  // No "already in the mode" guard, because the key branch cannot deliver one:
13339
14735
  // while the mode is on, the entry chord's own key is not one of the keys the
13340
14736
  // mode names, so it takes the hand-back path and ends the mode instead. The
@@ -13353,37 +14749,107 @@
13353
14749
  return true;
13354
14750
  }
13355
14751
 
14752
+ // Task 9 review I2: real DOM focus can land on a genuine standalone
14753
+ // control — `#doc-search-input` (backlog #9's own fix moves focus there
14754
+ // on purpose) or a `textarea.ed-raw` (backlog #6's quote/code/line fix
14755
+ // opens one) — while toolbarRovingIndex is STILL >= 0, because neither
14756
+ // fix touches it (the F12 cursor is virtual by design: "no toolbar
14757
+ // button ever takes DOM focus", see F12's own load-bearing comment
14758
+ // above). MEASURED (review): with focus in `#doc-search-input` holding
14759
+ // 'needle' at selectionStart 6, `Home`/`End`/`ArrowLeft` were ALL still
14760
+ // being read as toolbar-cursor commands (Home/End jumped the BAR to its
14761
+ // first/last button, ArrowLeft moved the bar cursor one step) instead of
14762
+ // moving the text cursor inside the input — the exact new keyboard
14763
+ // dead-end backlog #9 was supposed to fix, now reachable through #9 and
14764
+ // #10 together.
14765
+ //
14766
+ // Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
14767
+ // not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
14768
+ // focusin comment is explicit that SOME toolbar activations (list,
14769
+ // ordered-list, check, table, insert-before, insert-after, preview)
14770
+ // deliberately land a focusin on an editable block WITHOUT ending keynav,
14771
+ // so a real block surface must not trip this bail.
14772
+ //
14773
+ // ⚠ FIX ROUND 1 (self-review, caught before ship): `preview`'s own landing
14774
+ // is `sourceTextarea` — a real `<textarea>`, not a `.ed-wys-armed` block —
14775
+ // so the exemption above named it but the first cut of this function did
14776
+ // not actually implement it, and the omission is load-bearing: source
14777
+ // mode's model disables every button except outline/preview (this file's
14778
+ // own "S/4.1 Source mode" comment — the cursor is meant to be walked with
14779
+ // Left/Right/Home/End between those two, THEN activated), and MEASURED,
14780
+ // that walk is exactly what this function's first cut broke — the very
14781
+ // next arrow key after landing in the source textarea silently exited
14782
+ // keynav instead of moving between the two enabled buttons, closing off
14783
+ // the one route backlog #6's own still-open `preview` round-trip gap (see
14784
+ // activateToolbarCursor()'s comment) names as "THE WAY OUT". `sourceTextarea`
14785
+ // is therefore excluded by reference (not by tag/class — an ordinary
14786
+ // `textarea.ed-raw`, e.g. backlog #6's own quote/code rescue, still trips
14787
+ // this bail exactly as intended, since typing there IS the user's own
14788
+ // text, not bar navigation).
14789
+ function toolbarKeynavBlockedByRealControl() {
14790
+ const ae = document.activeElement;
14791
+ if (!ae || ae === sourceTextarea) return false;
14792
+ return !!(ae.tagName && (ae.tagName === 'INPUT' ||
14793
+ ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
14794
+ }
14795
+
13356
14796
  // Activation goes through the same call the click handler makes, so a
13357
14797
  // keyboard press and a mouse press cannot drift apart.
13358
14798
  //
13359
14799
  // WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
13360
14800
  // 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.
14801
+ // typing to see whether the characters land anywhere — UPDATED for Task 9
14802
+ // (backlog #6), which fixed the first two bullets below; only the third
14803
+ // remains an open gap, tracked below rather than left unstated:
14804
+ //
14805
+ // * FIXED: quote, code and line used to end on BODY with typing going
14806
+ // nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
14807
+ // `allowRawEditRescue` (convertBlockViaMenu() only — see its own
14808
+ // comment) and insertBlockBelow()'s `kind === 'line'` branch now open
14809
+ // that block's raw editor instead, so typing lands there.
14810
+ // * FIXED: undo, redo and image used to join them ONLY when the surface
14811
+ // had an uncommitted edit (switchAwayFrom()'s own commit-and-render is
14812
+ // what dropped the caret). undoViaToolbar()/redoViaToolbar()/
14813
+ // imageViaToolbar() now wrap the same captureCaretForSave()/
14814
+ // restoreCaretForSave() pair saveViaToolbar() uses.
14815
+ // * STILL OPEN: `preview` (cycleDocMode(), the document-wide edit↔source
14816
+ // toggle) is UNCHANGED — going INTO source mode focuses the source
14817
+ // textarea correctly, but the RETURN trip (source → edit) still leaves
14818
+ // the caret on BODY, MEASURED unchanged by this task's own review.
14819
+ // Deliberately left open rather than rushed: unlike the two bullets
14820
+ // above, there is no established rescue to reuse here —
14821
+ // captureCaretForSave()'s block-id + text-offset coordinate system has
14822
+ // no meaning for a source-textarea cursor position (a raw character
14823
+ // offset into the WHOLE document string, not one block's rendered
14824
+ // text), so a correct fix needs its own coordinate mapping and its own
14825
+ // tests, not a copy-paste of this task's other two fixes. Backlog #6's
14826
+ // first two clauses no longer make this escape hatch NECESSARY the way
14827
+ // they used to (quote/code/line now have their own direct rescue,
14828
+ // rather than needing the preview round-trip as their only way out),
14829
+ // which is why this is deferred rather than treated as still blocking.
14830
+ //
14831
+ // A mouse click on those same buttons does exactly the same thing, so none
14832
+ // of this is something the keyboard route introduced — a mouse user can
14833
+ // always climb out of the still-open `preview` case by clicking into any
14834
+ // OTHER editable text on the page, where a keyboard user cannot, because Tab
14835
+ // from BODY goes nowhere (this file's own §3.5 contract).
14836
+ //
14837
+ // THE WAY OUT for the still-open case, driven end to end from a stranded
14838
+ // BODY: the entry chord still works there (the bar's own state survives,
14839
+ // and its row is fully enabled), so walking to the mode button and pressing
14840
+ // Enter puts the caret in the source textarea, where typing lands. Coming
14841
+ // back the same way returns the document to edit mode but leaves the caret
14842
+ // on BODY again — the hatch is somewhere to keep typing, not (yet) a way
14843
+ // back to the WYSIWYG caret.
13383
14844
  function activateToolbarCursor() {
13384
14845
  const btn = toolbarRovingBtns()[toolbarRovingIndex];
13385
14846
  if (!btn || btn.disabled) return;
13386
- Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn))
14847
+ // Task 9 review M3: `true` — this IS the keyboard activation path (the
14848
+ // only caller of runToolbarAction() that is), threaded through so
14849
+ // toggleOutlineSidebar() can tell a keyboard Enter/Space apart from a
14850
+ // mouse click on the identical button. See its own comment for why
14851
+ // `toolbarRovingIndex >= 0` alone was the wrong test.
14852
+ Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn, true))
13387
14853
  .catch(() => {}).then(updateToolbar);
13388
14854
  }
13389
14855
 
@@ -13479,17 +14945,22 @@
13479
14945
  return bar;
13480
14946
  }
13481
14947
 
13482
- async function runToolbarAction(id, btnEl) {
14948
+ async function runToolbarAction(id, btnEl, viaKeyboard) {
13483
14949
  const blockEl = liveToolbarBlock();
13484
14950
  switch (id) {
14951
+ // v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
14952
+ // comment for why (commitThenSave()'s precondition and its own caret
14953
+ // rescue). backlog #6 gave undo()/redo() the same rescue below, via
14954
+ // undoViaToolbar()/redoViaToolbar() — see their shared comment.
14955
+ case 'save': return saveViaToolbar();
13485
14956
  // ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
13486
14957
  // `stack.redo`. Those are the pure core's helpers (they are what
13487
14958
  // rollbackFailedRender() reverses an op with) and calling them from
13488
14959
  // here would skip switchAwayFrom() and safeRerenderAll() — the exact
13489
14960
  // shape this file records as having produced a silent total lockout
13490
14961
  // recoverable only by reloading the page.
13491
- case 'undo': return undo();
13492
- case 'redo': return redo();
14962
+ case 'undo': return undoViaToolbar();
14963
+ case 'redo': return redoViaToolbar();
13493
14964
  case 'headings': return toggleToolbarHeadingMenu(btnEl);
13494
14965
  // The five conversion targets are convert-md.js's own ids, so the
13495
14966
  // gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
@@ -13513,24 +14984,76 @@
13513
14984
  case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
13514
14985
  case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
13515
14986
  case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
13516
- case 'image': return pickAndInsertImage();
13517
- case 'outline': return toggleOutlineSidebar();
14987
+ // backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
14988
+ // See its own comment (shared with undoViaToolbar()/redoViaToolbar())
14989
+ // for the caret rescue.
14990
+ case 'image': return imageViaToolbar();
14991
+ case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
13518
14992
  case 'preview': return cycleDocMode();
13519
14993
  default: return undefined;
13520
14994
  }
13521
14995
  }
13522
14996
 
13523
14997
  // ── 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.
14998
+ // backlog #7 fix: the panel now carries its OWN roving cursor
14999
+ // (toolbarMenuIndex), a second index alongside the bar's toolbarRovingIndex
15000
+ // rather than a repurposing of it — the bar's cursor stays parked on
15001
+ // 'headings' the whole time the menu is open (K6's own pinned assertion:
15002
+ // Escape's first press takes the menu down and LEAVES `at: 'headings'`),
15003
+ // so the panel needed a cursor of its own rather than borrowing the bar's.
15004
+ // -1 means "nothing in the panel highlighted yet" — the state right after
15005
+ // opening, and the state K6 already pins Enter/Space's behaviour against
15006
+ // (see activateToolbarMenuCursor()'s own comment for why that case is not
15007
+ // reinterpreted as "select the first item").
15008
+ let toolbarMenuIndex = -1;
15009
+
15010
+ function toolbarMenuItems() {
15011
+ return toolbarMenuEl
15012
+ ? Array.prototype.slice.call(toolbarMenuEl.querySelectorAll('.ed-toolbar-menu-btn')) : [];
15013
+ }
15014
+
15015
+ function paintToolbarMenuCursor() {
15016
+ toolbarMenuItems().forEach((b, i) => {
15017
+ if (i === toolbarMenuIndex) b.setAttribute('data-ed-tb-menu-cursor', '');
15018
+ else b.removeAttribute('data-ed-tb-menu-cursor');
15019
+ });
15020
+ }
15021
+
15022
+ // None of the six items are ever disabled (every heading depth 1-6 is
15023
+ // always a legal target), so this is a plain wrapping step — no
15024
+ // moveToolbarCursor()-style "skip disabled" walk is needed.
15025
+ function moveToolbarMenuCursor(delta) {
15026
+ const items = toolbarMenuItems();
15027
+ if (!items.length) return;
15028
+ toolbarMenuIndex = ((toolbarMenuIndex + delta) % items.length + items.length) % items.length;
15029
+ paintToolbarMenuCursor();
15030
+ items[toolbarMenuIndex].scrollIntoView({ block: 'nearest' });
15031
+ }
15032
+
15033
+ // K6 (test/editor-journey.test.js) pins Enter/Space, pressed with NOTHING
15034
+ // in the panel highlighted, as the H▾ button's own open/close toggle — the
15035
+ // same activateToolbarCursor() the bar's Enter/Space already runs for
15036
+ // every other button. Only once the user has actually moved the panel
15037
+ // cursor (toolbarMenuIndex >= 0, via ArrowUp/ArrowDown below) does
15038
+ // Enter/Space switch to activating that ITEM instead — reusing the item's
15039
+ // own click handler via .click() so keyboard and mouse activation are the
15040
+ // same code path, the same discipline activateToolbarCursor() itself
15041
+ // documents for the bar's buttons.
15042
+ function activateToolbarMenuCursor() {
15043
+ const items = toolbarMenuItems();
15044
+ const item = toolbarMenuIndex >= 0 ? items[toolbarMenuIndex] : null;
15045
+ if (!item) { activateToolbarCursor(); return; }
15046
+ item.click();
15047
+ }
15048
+
13528
15049
  function closeToolbarMenu() {
13529
15050
  if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
15051
+ toolbarMenuIndex = -1;
13530
15052
  }
13531
15053
 
13532
15054
  function toggleToolbarHeadingMenu(btnEl) {
13533
15055
  if (toolbarMenuEl) { closeToolbarMenu(); return; }
15056
+ toolbarMenuIndex = -1;
13534
15057
  const menu = document.createElement('div');
13535
15058
  menu.className = 'ed-toolbar-menu';
13536
15059
  [1, 2, 3, 4, 5, 6].forEach((n) => {
@@ -13683,15 +15206,28 @@
13683
15206
  // lib/md2doc.js. Named for the non-default state because the desktop
13684
15207
  // outline is a sticky column shown by default, which is also what lets
13685
15208
  // aria-expanded below stay truthful on both viewports.
13686
- function toggleOutlineSidebar() {
15209
+ function toggleOutlineSidebar(viaKeyboard) {
13687
15210
  // The same 1080px breakpoint the stylesheet's own drawer block uses.
13688
15211
  const mobile = typeof window.matchMedia === 'function' &&
13689
15212
  window.matchMedia('(max-width: 1080px)').matches;
13690
15213
  let expanded;
13691
15214
  if (mobile) {
15215
+ // Mirror onto documentElement too, exactly as lib/md2doc.js's own
15216
+ // setSidebarOpen() does — the scroll lock is `html[data-sidebar-open]
15217
+ // { overflow: hidden }` (documentElement, not body, is what a real
15218
+ // gesture actually scrolls; see that rule's comment in lib/md2doc.js).
15219
+ // Final-review C1: this toolbar entry point set only `body`, so the
15220
+ // drawer visually opened but the scroll lock never engaged — the ONLY
15221
+ // way into the drawer in edit mode (`.sidebar-toggle` is display:none
15222
+ // there, see T11-2) left backlog #12 unfixed for edit mode specifically.
13692
15223
  expanded = !document.body.hasAttribute('data-sidebar-open');
13693
- if (expanded) document.body.setAttribute('data-sidebar-open', '');
13694
- else document.body.removeAttribute('data-sidebar-open');
15224
+ if (expanded) {
15225
+ document.body.setAttribute('data-sidebar-open', '');
15226
+ document.documentElement.setAttribute('data-sidebar-open', '');
15227
+ } else {
15228
+ document.body.removeAttribute('data-sidebar-open');
15229
+ document.documentElement.removeAttribute('data-sidebar-open');
15230
+ }
13695
15231
  } else {
13696
15232
  expanded = document.body.hasAttribute('data-ed-outline-hidden');
13697
15233
  if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
@@ -13699,6 +15235,42 @@
13699
15235
  }
13700
15236
  const toggle = document.getElementById('sidebar-toggle');
13701
15237
  if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
15238
+ // backlog #9: v3.3.0 gave a keyboard route to OPEN the drawer (this very
15239
+ // button, reachable through the toolbar's F12 keynav) but none to
15240
+ // OPERATE what is inside it — the TOC links and the search box
15241
+ // (`#doc-search-input`, lib/md2doc.js's reader-sidebar markup) were
15242
+ // unreachable by keyboard once the drawer was open, because F12's
15243
+ // cursor is VIRTUAL (see its own load-bearing comment: "no toolbar
15244
+ // button ever takes DOM focus") and a bare Tab with NOTHING focused is
15245
+ // deliberately swallowed elsewhere in this file (§3.5's block-indent
15246
+ // contract, a few hundred lines down) — so a keyboard user who had
15247
+ // never clicked into a real control was left with no Tab destination at
15248
+ // all, drawer open or not.
15249
+ //
15250
+ // Scoped to the KEYBOARD path only: a mouse click reaches this same
15251
+ // function, and buildToolbar()'s own mousedown-preventDefault() note is
15252
+ // explicit that a toolbar press must NOT steal focus from whatever
15253
+ // burst the mouse user has open — forcing focus into the search box on
15254
+ // every click would blur that burst (committing it) for no reason a
15255
+ // mouse user asked for. Also scoped to `expanded`: a toggle that just
15256
+ // HID the drawer has nowhere sensible to send focus.
15257
+ //
15258
+ // Task 9 review M3: `viaKeyboard`, an explicit parameter threaded from
15259
+ // activateToolbarCursor() — NOT `toolbarRovingIndex >= 0`, which was
15260
+ // the first cut's test and is wrong: toolbarRovingIndex says whether
15261
+ // keynav is CURRENTLY on, not which INPUT DEVICE fired this particular
15262
+ // activation. MEASURED: press Alt+F10 (keynav now on), then click ☰
15263
+ // with the MOUSE — toolbarRovingIndex is still >= 0 from the earlier
15264
+ // keypress, so the old test treated a plain mouse click as "keyboard",
15265
+ // stealing focus into the search box exactly as the commit message for
15266
+ // backlog #9 says it must not ("a mouse click on the same button must
15267
+ // not steal focus"). `viaKeyboard` is true on ONE call path only —
15268
+ // activateToolbarCursor(), which IS the keyboard route — regardless of
15269
+ // whatever toolbarRovingIndex happens to hold at the time.
15270
+ if (expanded && viaKeyboard) {
15271
+ const search = document.getElementById('doc-search-input');
15272
+ if (search) search.focus();
15273
+ }
13702
15274
  }
13703
15275
 
13704
15276
  // ── 追加 3: images ─────────────────────────────────────────────────────
@@ -13730,7 +15302,13 @@
13730
15302
  // authoritative.
13731
15303
  async function uploadImageBlob(blob) {
13732
15304
  if (!blob) return null;
13733
- if (!assetLib || !assetLib.extFor(blob.type)) {
15305
+ // A .drawio arrives with `type === ''` -- browsers have no media type for
15306
+ // it -- so `extFor()` alone answered "unsupported" without ever asking the
15307
+ // server. The name is enough to get the request SENT; it is not enough to
15308
+ // get anything written: /api/asset re-checks the extension against its own
15309
+ // two literals and then parses the bytes. Keep the two rules the same
15310
+ // shape, and let the server's refusal be the one the user sees.
15311
+ if (!insertableFile(blob)) {
13734
15312
  showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
13735
15313
  return null;
13736
15314
  }
@@ -13760,6 +15338,15 @@
13760
15338
  }
13761
15339
  let j;
13762
15340
  try { j = await res.json(); } catch (e) { j = null; }
15341
+ // A refusal carries a reason; saying "伺服器回應格式不正確" instead is a
15342
+ // second wrong answer on top of the first. The content gate added in
15343
+ // v3.4.1 makes this reachable for an ordinary mistake -- picking an .xml
15344
+ // that is not a draw.io diagram -- so the reason has to arrive.
15345
+ if (!res.ok) {
15346
+ const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
15347
+ showBanner('檔案上傳失敗 — ' + why, null, null);
15348
+ return null;
15349
+ }
13763
15350
  if (!j || typeof j.path !== 'string') {
13764
15351
  showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
13765
15352
  return null;
@@ -13873,7 +15460,13 @@
13873
15460
  const dialogIdentity = captureBlockIdentity(anchorEl);
13874
15461
  const input = document.createElement('input');
13875
15462
  input.type = 'file';
13876
- input.accept = 'image/png,image/jpeg,image/gif,image/webp';
15463
+ // v3.4.1: extension tokens sit beside the MIME tokens deliberately.
15464
+ // `.drawio` has no registered media type, so a MIME-only accept list
15465
+ // cannot name it and the dialog greys the file out -- which is how
15466
+ // v3.4.0 shipped a renderer that embeds .drawio/.xml next to an insert
15467
+ // button that could not pick one. Mixing the two token kinds is what
15468
+ // the attribute is specified for.
15469
+ input.accept = 'image/png,image/jpeg,image/gif,image/webp,.drawio,.xml';
13877
15470
  input.multiple = true;
13878
15471
  input.hidden = true;
13879
15472
  // (a) above.
@@ -13897,6 +15490,21 @@
13897
15490
  input.click();
13898
15491
  }
13899
15492
 
15493
+ // backlog #6 — see undoViaToolbar()/redoViaToolbar()'s shared comment.
15494
+ // pickAndInsertImage() resolves (its own switchAwayFrom() included) well
15495
+ // before the user has picked a file — the `input.click()` above only OPENS
15496
+ // the native file dialog, it does not await a selection — so capturing
15497
+ // before and restoring right after this call lands at exactly the same
15498
+ // point in the gesture saveViaToolbar()'s pair does for `save`: after the
15499
+ // commit-and-render that can strand the caret, before the (much later,
15500
+ // asynchronous, maybe-never-arrives) file selection that focuses somewhere
15501
+ // new anyway.
15502
+ async function imageViaToolbar() {
15503
+ const saved = captureCaretForSave();
15504
+ await pickAndInsertImage();
15505
+ restoreCaretForSave(saved);
15506
+ }
15507
+
13900
15508
  function dragCarriesFiles(e) {
13901
15509
  const dt = e.dataTransfer;
13902
15510
  if (!dt || !dt.types) return false;
@@ -13924,18 +15532,26 @@
13924
15532
  // user is looking at is not the content a drop would write into" — a
13925
15533
  // future third mode inherits the refusal rather than the hole.
13926
15534
  document.addEventListener('dragover', (e) => {
15535
+ // Nothing is dropped into a document behind a modal. Returning WITHOUT
15536
+ // preventDefault() is what makes the browser refuse the drop, so the
15537
+ // gesture is declined visibly rather than swallowed.
15538
+ if (waveEditorIsModal()) return;
13927
15539
  if (docMode !== 'edit') return;
13928
15540
  if (!dragCarriesFiles(e)) return;
13929
15541
  e.preventDefault();
13930
15542
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
13931
15543
  });
13932
15544
  document.addEventListener('drop', (e) => {
15545
+ // MEASURED before this line existed: a PNG dropped on the open wave panel
15546
+ // took the document behind it from 4 blocks to 5 and put an `<img>` into
15547
+ // `.content`, with the overlay still up and saying nothing.
15548
+ if (waveEditorIsModal()) return;
13933
15549
  if (docMode !== 'edit') return;
13934
15550
  if (!dragCarriesFiles(e)) return;
13935
15551
  const files = e.dataTransfer
13936
15552
  ? Array.prototype.slice.call(e.dataTransfer.files || [])
13937
15553
  : [];
13938
- const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
15554
+ const images = files.filter(insertableFile);
13939
15555
  if (!images.length) return; // not ours: let the page do whatever it would
13940
15556
  e.preventDefault();
13941
15557
  const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
@@ -13955,8 +15571,15 @@
13955
15571
  try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
13956
15572
  const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
13957
15573
  for (let i = 0; i < files.length; i += 1) {
13958
- if (files[i] && String(files[i].type).indexOf('image/') === 0) {
13959
- items[files[i].type] = files[i];
15574
+ // This map is keyed by MIME, and a .drawio has no MIME at all, so a
15575
+ // diagram pasted as a FILE goes under the fixed 'file' key that
15576
+ // pickPayload() also looks at. The key is a protocol constant between
15577
+ // these two modules -- the RULE for which files are insertable stays
15578
+ // in insertableFile() alone, so paste-md.js never grows a second
15579
+ // opinion about extensions.
15580
+ if (files[i] && insertableFile(files[i])) {
15581
+ const mime = String(files[i].type || '');
15582
+ items[mime.indexOf('image/') === 0 ? mime : 'file'] = files[i];
13960
15583
  break;
13961
15584
  }
13962
15585
  }
@@ -14023,7 +15646,9 @@
14023
15646
  // commitBlockInsertion()/replaceLines() and therefore through
14024
15647
  // serializeBlocks()'s existing gates, instead of arriving as a pile of
14025
15648
  // foreign nodes the serializer would have to make sense of afterwards.
14026
- insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
15649
+ const pastedLines = md.split('\n');
15650
+ insertBlockBelow(editEl.closest('.ed-block'), 'paragraph',
15651
+ { lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
14027
15652
  return;
14028
15653
  }
14029
15654
  snapBurstIfActive(editEl, 'paste-pre');
@@ -14035,6 +15660,7 @@
14035
15660
  // Capture phase: this only records intent, and must do so before any
14036
15661
  // other keydown handler can preventDefault() its way past it.
14037
15662
  document.addEventListener('keydown', (e) => {
15663
+ if (waveEditorIsModal()) return;
14038
15664
  plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
14039
15665
  (e.key === 'v' || e.key === 'V'));
14040
15666
  }, true);
@@ -14144,6 +15770,15 @@
14144
15770
  updateToolbar();
14145
15771
  });
14146
15772
  document.addEventListener('click', (e) => {
15773
+ // v3.4.0 batch3 Task 6: this file has TWO document-level `click`
15774
+ // delegates, and for one round only the other one asked. Nothing behind
15775
+ // the modal takes clicks — measured benign here today (the toolbar menu
15776
+ // cannot be open behind a modal, keynav cannot be entered while the main
15777
+ // keydown returns first, and `closest('.ed-block')` is null for anything
15778
+ // in the overlay), but "benign today" is a property of the three branches
15779
+ // below rather than of the boundary, and the next branch added here would
15780
+ // inherit an unguarded seat behind a modal.
15781
+ if (waveEditorIsModal()) return;
14147
15782
  if (!e.target || !e.target.closest) return;
14148
15783
  if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
14149
15784
  closeToolbarMenu();
@@ -14262,24 +15897,134 @@
14262
15897
  // keydown ends the mode and the arrow behind it falls through to
14263
15898
  // the block-selection extension instead.
14264
15899
  //
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();
15900
+ // A menu raised from this bar goes down on the way past — UNLESS the key
15901
+ // is one the panel itself now claims (backlog #7, see the nested
15902
+ // `if (toolbarMenuEl)` just below). Before that fix ALL of
15903
+ // Up/Down/Enter/Space fell through this whole file with the menu open
15904
+ // and its six items unreachable by keyboard; without the panel going
15905
+ // down too on everything else, the hand-back cleared the cursor cue and
15906
+ // left the H▾ dropdown standing, so the two disagreed and only Escape
15907
+ // was any use. Escape is excluded because the ordered prologue below
15908
+ // owns it and takes the menu down there, in rank order.
15909
+ // Task 9 review I2: a real control (search box, raw editor textarea)
15910
+ // holding focus quietly ends keynav and hands EVERY key straight to it
15911
+ // — see toolbarKeynavBlockedByRealControl()'s own comment. No banner:
15912
+ // this is not a stray key with nowhere to go, it is the user typing
15913
+ // into something they can see is focused.
15914
+ // ── v3.4.0 batch3 Task 6: the wave editor owns the keyboard, EXCEPT ──
15915
+ // Above everything, including the toolbar's keyboard mode. While that
15916
+ // overlay is up it is modal over the document, and almost every branch
15917
+ // below this line is about a document the user cannot see or reach —
15918
+ // including the `Tab` branch, which used to swallow the key and leave all
15919
+ // of an `aria-modal` dialog's controls unreachable. The overlay's own
15920
+ // capture-phase handler owns Escape, Tab and undo/redo and stops them
15921
+ // before they get here; this is the other half of that deal, stated on this
15922
+ // side so the two cannot drift into disagreeing about who owns what.
15923
+ //
15924
+ // SAVE IS THE EXCEPTION, and it is the exception because ownership is about
15925
+ // which gestures, not only about who. Ctrl+S is not a gesture aimed at the
15926
+ // document the modal is covering — it is the reflex that protects work, and
15927
+ // the wave editor has nothing of its own to save in this task. Swallowing
15928
+ // it cost twice over, both measured: the keystroke did nothing and said
15929
+ // nothing (`disk has MARKA? false` with the overlay open, `true` after
15930
+ // Escape), and — worse — `showConflictBanner()` has exactly ONE producer,
15931
+ // the 409 branch of `save()`, while the toolbar's save control sits under an
15932
+ // `inset: 0` backdrop and outside the focus trap. So with Ctrl+S swallowed,
15933
+ // a disk conflict arising DURING a wave session could not raise a banner at
15934
+ // all: measured, file changed underneath, overlay open, Ctrl+S, banner
15935
+ // `null`. All the work keeping `.ed-conflict` above the modal and inside the
15936
+ // focus trap only ever fired for a banner that predated the overlay.
15937
+ //
15938
+ // ⚠ fix 4 (R1): something else now DEPENDS on this line being as narrow as
15939
+ // it is. `waveDiscardIsSafe()` decides whether a wave session's commits can
15940
+ // be popped off the document stack by comparing a COUNT
15941
+ // (`depth === baseDepth + ops`), which proves only that the same number of
15942
+ // entries sit above where the session started — a foreign push paired with
15943
+ // a foreign pop inside one session satisfies it, and
15944
+ // `discardPristineInsert()` is exactly that shape. It is unreachable today
15945
+ // because this gate returns on every non-save key and because
15946
+ // `openWaveEditor()` resolves the one remaining path (Ctrl+S's
15947
+ // `switchAwayFrom()`) BEFORE the overlay opens. Letting one more gesture
15948
+ // through here re-opens that hole silently; if that is ever the right call,
15949
+ // waveDiscardIsSafe()'s check has to become identity-based first.
15950
+ if (waveEditorIsModal() && !isSaveGesture(e)) return;
15951
+ if (toolbarRovingIndex >= 0 && toolbarKeynavBlockedByRealControl()) {
15952
+ exitToolbarKeynav();
15953
+ } else if (toolbarRovingIndex >= 0) {
15954
+ // backlog #7: the H▾ panel claims Up/Down/Enter/Space for its OWN
15955
+ // roving cursor before anything below gets a look at them — see
15956
+ // toolbarMenuIndex's own comment for why that is a second index
15957
+ // rather than a repurposing of toolbarRovingIndex. Every other key
15958
+ // (Left/Right/Home/End/Tab/…) still closes the panel exactly as
15959
+ // before, which is why the fallback line at the end here is the
15960
+ // ORIGINAL condition with the now-claimed keys still excluded from it
15961
+ // (Escape stays excluded too — the ordered prologue below owns it, in
15962
+ // rank order, same as always).
15963
+ if (toolbarMenuEl) {
15964
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveToolbarMenuCursor(1); return; }
15965
+ if (e.key === 'ArrowUp') { e.preventDefault(); moveToolbarMenuCursor(-1); return; }
15966
+ if (e.key === 'Enter' || e.key === ' ') {
15967
+ e.preventDefault(); activateToolbarMenuCursor(); return;
15968
+ }
15969
+ if (e.key !== 'Escape') closeToolbarMenu();
14274
15970
  }
14275
15971
  if (e.key === 'ArrowRight') { e.preventDefault(); moveToolbarCursor(1); return; }
14276
15972
  if (e.key === 'ArrowLeft') { e.preventDefault(); moveToolbarCursor(-1); return; }
15973
+ // backlog #10: Home/End are the ARIA toolbar pattern's own "jump to
15974
+ // the first/last item" keys — a natural extension of the roving pair
15975
+ // just above, not a new idiom. Naming them here (instead of letting
15976
+ // them fall to the hand-back branch below) turns two of the seven
15977
+ // keys MEASURED to silently drop the cursor into something USEFUL.
15978
+ if (e.key === 'Home') { e.preventDefault(); moveToolbarCursorToEdge(-1); return; }
15979
+ if (e.key === 'End') { e.preventDefault(); moveToolbarCursorToEdge(1); return; }
14277
15980
  if (e.key === 'Enter' || e.key === ' ') {
14278
15981
  e.preventDefault(); activateToolbarCursor(); return;
14279
15982
  }
15983
+ // backlog #10: everything else hands the key back ON PURPOSE — Tab
15984
+ // moves real DOM focus per the document's own tab order (see
15985
+ // buildToolbar()'s own "Tab key belongs to the document's own
15986
+ // structure" note), and a plain character or Backspace has nowhere to
15987
+ // land while the virtual cursor sits over a non-editable BODY, so
15988
+ // letting them fall through is harmless. What is NOT harmless,
15989
+ // MEASURED (v3.3.0): the hand-back left NOTHING on screen to mark it —
15990
+ // `[data-ed-tb-cursor]` and `data-ed-tb-keynav` both went null with the
15991
+ // bar looking exactly as it did the instant before Alt+F10 was first
15992
+ // pressed, so a user who does not catch the outline vanishing has no
15993
+ // way to tell they fell out of keyboard mode. Two fixes were on the
15994
+ // table (v3.3.0's own ruling: 「一併處理,加上可見的信號」 — do both):
15995
+ // teach the specific keys that have somewhere useful to go (Home/End,
15996
+ // above) to stop exiting, AND raise a visible banner on every hand-back
15997
+ // that still exits. Enumerating "every key with nowhere useful to go"
15998
+ // was rejected as the SOLE fix — that list is open-ended and this file
15999
+ // cannot audit it for completeness, while a banner on every hand-back
16000
+ // is correct for any key this branch is ever reached by, including
16001
+ // ones nobody has measured yet.
16002
+ //
16003
+ // Task 9 review I3: `Tab` is excluded from the banner here (though it
16004
+ // still exits keynav, same as before) — it is the ARIA toolbar
16005
+ // pattern's OWN "leave the toolbar" gesture, a legitimate exit rather
16006
+ // than a stray key with nowhere to go, and raising a full-width red
16007
+ // banner over the whole bar for a correct keystroke was the wrong
16008
+ // direction. MEASURED AT THE TIME: dismissKeynavExitBanner() was the
16009
+ // only way a keyboard user could take the earlier, unconditional
16010
+ // banner back down at all (`.ed-conflict` was `z-index:999` over the
16011
+ // bar's own `z-index:101` AND `top:0` against the bar's own `top:0`,
16012
+ // covering all 23 buttons; a bare Tab with nothing focused is
16013
+ // swallowed by this file's own §3.5 contract, so Escape was the only
16014
+ // route left) — excluding Tab here removes most of the cases that
16015
+ // needed that route in the first place, and Escape's own rung below
16016
+ // closes the rest. Task 10 (backlog #8) has since moved `.ed-conflict`
16017
+ // to `top: var(--ed-toolbar-h)`, so it no longer covers the bar for
16018
+ // ANY banner — the "covering all 23 buttons" clause above is
16019
+ // historical (what justified wiring Escape at the time), not the
16020
+ // current geometry.
14280
16021
  if (e.key !== 'Escape' && e.key !== 'Alt' && e.key !== 'Shift' &&
14281
16022
  e.key !== 'Control' && e.key !== 'Meta') {
14282
16023
  exitToolbarKeynav();
16024
+ if (e.key !== 'Tab') {
16025
+ showBanner(TOOLBAR_KEYNAV_EXIT_MESSAGE, null, null);
16026
+ activeBannerIsKeynavExit = true;
16027
+ }
14283
16028
  }
14284
16029
  } else if (e.altKey && e.key === 'F10') {
14285
16030
  e.preventDefault();
@@ -14353,6 +16098,13 @@
14353
16098
  // bar, so it goes out before the bar's keyboard mode does, matching the
14354
16099
  // way the menu rungs above rank against the selection rung below.
14355
16100
  if (toolbarMenuEl) { e.preventDefault(); closeToolbarMenu(); return; }
16101
+ // Task 9 review I3: the keynav-exit banner (backlog #10) is only ever
16102
+ // up once keynav has ALREADY exited, so this and the
16103
+ // exitToolbarKeynav() rung right below it never both apply to the
16104
+ // same Escape — order between the two is not load-bearing, grouped
16105
+ // here only for readability. See dismissKeynavExitBanner()'s own
16106
+ // comment for why Escape, specifically, is safe to wire to it.
16107
+ if (dismissKeynavExitBanner()) { e.preventDefault(); return; }
14356
16108
  if (exitToolbarKeynav()) { e.preventDefault(); return; }
14357
16109
  if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
14358
16110
  // Nothing above owns this Escape — fall through to the burst
@@ -14372,6 +16124,17 @@
14372
16124
  // commit failure switchAwayFrom() returns false — the banner it
14373
16125
  // already showed is the visible reason save() is skipped; the burst
14374
16126
  // stays open with the user's text intact rather than saving nothing.
16127
+ //
16128
+ // v3.4.0 §3: this exact precondition — resolve first, save second —
16129
+ // is now also named once as commitThenSave(), for the toolbar `save`
16130
+ // button (see its own comment). Left spelled out here rather than
16131
+ // switched to call it: this line is pinned verbatim by a static
16132
+ // source-shape check in test/editor-client.test.js's
16133
+ // shortcutOrderChecks, which is outside this task's file scope, and
16134
+ // the two spellings are behaviourally identical fire-and-forget calls
16135
+ // — nothing is duplicated here beyond this one line shape, and
16136
+ // save()'s own body is still called exactly once, from inside
16137
+ // commitThenSave() when the toolbar path runs it.
14375
16138
  switchAwayFrom().then((ok) => { if (ok) save(); });
14376
16139
  return;
14377
16140
  }
@@ -14447,7 +16210,19 @@
14447
16210
  // `blockSelection` is the gate, and it is the whole guard: with no set
14448
16211
  // standing there is no focus holder either, and every key below behaves
14449
16212
  // exactly as it did pre-S3.
14450
- if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey) {
16213
+ //
16214
+ // v3.4.0 batch3 Task 6: that invariant is now CHECKED and not assumed. It
16215
+ // was true only because nothing had ever mounted a text field outside
16216
+ // `.content`; the wave editor puts real `<input>`s on document.body, which
16217
+ // are neither `.ed-wys-armed` nor `.ed-wys-cell` either, and a Backspace
16218
+ // typed into one ran deleteSelection() — MEASURED: 3 blocks became 2, the
16219
+ // tail paragraph was gone from the document, and the keystroke never
16220
+ // reached the input at all. Silent data loss behind a modal. A guard that
16221
+ // holds only because nobody has done the thing it forbids is not a guard,
16222
+ // so the text-surface test is spelled out rather than inferred from two
16223
+ // class names, and it protects every future surface too.
16224
+ if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey &&
16225
+ !isTextEntryTarget(e.target)) {
14451
16226
  if (e.key === 'Tab') {
14452
16227
  // §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
14453
16228
  // 瀏覽器焦點巡覽.
@@ -14468,6 +16243,52 @@
14468
16243
  }
14469
16244
  }
14470
16245
 
16246
+ // v3.4.0 batch3 Task 8: the wave editor's KEYBOARD entry.
16247
+ //
16248
+ // Until this, the only way into it was the `編輯波形` affordance, which is
16249
+ // raised by a `mouseover` and positioned over the diagram — so a keyboard
16250
+ // user could not open it at all. That is the same defect this file has
16251
+ // already paid for twice on other surfaces: an affordance only a pointer
16252
+ // can raise (v3.3.0's `H▾` dropdown) and a panel with no keyboard route
16253
+ // into its contents (the outline drawer, backlog #9).
16254
+ //
16255
+ // Gated on the BLOCK WRAPPER ITSELF, not on `blockSelection` and not on
16256
+ // `closest()`: the focus holder is the same `.ed-block` element in both of
16257
+ // the states that can get here — a standing selection (whose roving
16258
+ // `tabindex` focuses it) and the focus this file hands back when a wave
16259
+ // session closes — so one branch serves the way in and the way back in.
16260
+ //
16261
+ // `closest('.ed-block')` is what the first cut used and it was wrong by
16262
+ // exactly this defect family: EVERY block contains an `.ed-insert` and an
16263
+ // `.ed-handle` <button>, so Enter on the ⠿ of a wavedrom block opened the
16264
+ // wave editor and `preventDefault()`ed the block menu, while SPACE on the
16265
+ // same button still opened the menu — one button, two devices'
16266
+ // conventions, two answers, which is v3.3.0 backlog #4 with the devices
16267
+ // swapped. That path also has no `blockSelection`, so closing the session
16268
+ // it wrongly opened would have ended on `document.body`. It was latent
16269
+ // only because nothing can put the keyboard on those buttons today, and it
16270
+ // would have gone live with the very follow-up this task is asking for.
16271
+ //
16272
+ // `isTextEntryTarget` keeps it away from every editable, where Enter
16273
+ // already means a paragraph break, and the modal gate far above means it
16274
+ // cannot fire while the overlay is up.
16275
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
16276
+ !isTextEntryTarget(e.target) && e.target && e.target.classList &&
16277
+ e.target.classList.contains('ed-block')) {
16278
+ const blockEl = e.target;
16279
+ const diagram = waveDiagramInside(blockEl);
16280
+ if (diagram !== null) {
16281
+ e.preventDefault();
16282
+ // Same shape as the affordance's own click handler: reported, not
16283
+ // rethrown. An unhandled rejection out of a keydown listener is a
16284
+ // page-level error, and openWaveEditor() unwinds its own state.
16285
+ openWaveEditor(blockEl).catch((err) => {
16286
+ console.error('md2doc: the wave editor could not open', err);
16287
+ });
16288
+ return;
16289
+ }
16290
+ }
16291
+
14471
16292
  // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
14472
16293
  // branch above is keyed on the event target being some edit surface, and
14473
16294
  // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
@@ -14761,6 +16582,12 @@
14761
16582
  });
14762
16583
 
14763
16584
  document.addEventListener('paste', (e) => {
16585
+ // Same question as `drop` above, and for the same reason: what the document
16586
+ // behind a modal receives is nothing. Today the two branches below already
16587
+ // require a `.ed-wys-cell` / `.ed-wys-armed` target, which a paste into a
16588
+ // wave field is not — this is here so the boundary is one predicate rather
16589
+ // than a property of which targets those two selectors happen to match.
16590
+ if (waveEditorIsModal()) return;
14764
16591
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
14765
16592
  if (cellEl) {
14766
16593
  e.preventDefault();
@@ -14930,25 +16757,1394 @@
14930
16757
  document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
14931
16758
 
14932
16759
  window.addEventListener('beforeunload', (e) => {
14933
- if (stack.dirtyDepth !== 0 || burstHasUncommittedEdit()) {
16760
+ // Review I1: documentIsDirty(), not a second copy of the expression it
16761
+ // wraps — this was the second caller still spelling it out by hand.
16762
+ if (documentIsDirty()) {
14934
16763
  e.preventDefault();
14935
16764
  e.returnValue = '';
14936
16765
  return '';
14937
16766
  }
14938
16767
  });
14939
16768
 
16769
+ // v3.4.0 batch2 Task 6 (Ruling B2-2) — background refresh for a referenced
16770
+ // `.drawio`/`.xml` file that changed on disk, triggered by the ping loop
16771
+ // below seeing `{stale:true}`.
16772
+ //
16773
+ // Deliberately NOT rerenderAll(): that function carries edit-commit
16774
+ // semantics nobody asked for here (dropDiscardedBurst/dropDiscardedRawEdit,
16775
+ // the swallowed-fence banner, `pendingSelectionRange` consumption) — this
16776
+ // only ever touches blocks whose OWN baked diagram actually differs, never
16777
+ // reassigns `blocks`/`lines` (the source text did not change, only a
16778
+ // referenced file's bytes did), and never moves focus out of an editor.
16779
+ //
16780
+ // ── Fix round 2 (re-review G1): ONE criterion for "is there a diagram here"
16781
+ //
16782
+ // The first cut asked two different questions. The pre-fetch guard asked the
16783
+ // DOM (`querySelector('.drawio')`); the per-block loop asked a text regex
16784
+ // over the part strings. They disagree on a block whose rendered markup
16785
+ // merely CONTAINS the literal — a fenced code block or a table cell
16786
+ // documenting this very feature, which this repo's own docs plausibly hold.
16787
+ // Three consequences, all of them real:
16788
+ // * a fenced code block selected as a diagram block and was replaced
16789
+ // wholesale on every re-bake (G3),
16790
+ // * the pre-fetch guard could not see a bail the loop would take, so with
16791
+ // F2's retry in place the caret resting in that code block cost a full
16792
+ // /api/render — headless Chromium, every page of every referenced
16793
+ // diagram — every 10 seconds, forever, with the diagram never updating,
16794
+ // * and because the bootstrap seed is the HTML parser's re-serialisation
16795
+ // rather than the server's own strings, the cheap `parts[i] ===
16796
+ // lastParts[i]` equality never fired on an unedited tab, so ONE external
16797
+ // write re-swapped EVERY diagram block on the page (G4).
16798
+ //
16799
+ // Both sides now ask the same question, and it is the structural one: does
16800
+ // this markup contain a real element with class `drawio`? The old side reads
16801
+ // the live node; the new side parses the part and reads that. Text that
16802
+ // merely quotes the markup parses to a text node inside <code> and is not
16803
+ // selected by anything. DRAWIO_TEXT_HINT survives only as a cheap SUPERSET
16804
+ // prefilter so the parse is skipped for the overwhelming majority of blocks
16805
+ // — it never decides anything on its own.
16806
+ //
16807
+ // And "did it actually change" is answered by a fingerprint of the baked
16808
+ // diagram taken through the SAME parser on both sides (so the seed's
16809
+ // non-byte-identity cannot leak in), deliberately ignoring `hidden` — that
16810
+ // is the reader's page selection, not part of the bake.
16811
+ const DRAWIO_TEXT_HINT = /class="[^"]*\bdrawio\b/;
16812
+ const DRAWIO_FP_SEP = '';
16813
+
16814
+ // Fix round 3 (re-review2 H2): ALL of them, not the first.
16815
+ // `![a](a.drawio) ![b](b.drawio)` on one markdown line is ONE block holding
16816
+ // TWO `.drawio` boxes — verified by render, and a table row of diagrams does
16817
+ // the same. Round 2 compared only `querySelector('.drawio')`, so a change to
16818
+ // the SECOND file left the comparison equal, the loop `continue`d, `applied`
16819
+ // stayed true, the tab ACKED, and the server advanced its baseline past an
16820
+ // update the DOM never took: silent, permanent, and exactly the invariant
16821
+ // F2 exists to hold. Everything below therefore works on the whole list.
16822
+ function drawioBoxesIn(node) {
16823
+ return (node && node.querySelectorAll)
16824
+ ? Array.prototype.slice.call(node.querySelectorAll('.drawio'))
16825
+ : [];
16826
+ }
16827
+ function drawioBoxIn(node) {
16828
+ return (node && node.querySelector) ? node.querySelector('.drawio') : null;
16829
+ }
16830
+ function parsePartMarkup(part) {
16831
+ const t = document.createElement('template');
16832
+ t.innerHTML = part;
16833
+ return t;
16834
+ }
16835
+ // Fix round 3 (re-review2 H3): the reader's annotation feature mutates the
16836
+ // live `.drawio-page` IN PLACE — `annoSyncInline()` (lib/md2doc.js) wraps the
16837
+ // visible <svg> in `<span class="anno-inline-wrap">` and appends an
16838
+ // `<svg class="anno-inline">` overlay beside it, and the wrap is never
16839
+ // removed again. The fresh parse of a part never carries either, so an
16840
+ // annotated diagram could NEVER fingerprint equal, and G4's whole point —
16841
+ // "a write to a.drawio must not touch the block holding b.drawio" — failed
16842
+ // for exactly the blocks carrying the reader's own work.
16843
+ //
16844
+ // Normalising undoes the wrap on a CLONE so the comparison sees the bake
16845
+ // again. MEASURED: normalising an annotated page yields a string byte-equal
16846
+ // to an unannotated page baked from the same file (1170 chars vs 2171 raw).
16847
+ // The clone only happens when the cheap `querySelector` says there is
16848
+ // something to undo, so an unannotated document pays one selector per page.
16849
+ function drawioPageMarkup(pageEl) {
16850
+ if (!pageEl.querySelector('.anno-inline-wrap')) return pageEl.innerHTML;
16851
+ const c = pageEl.cloneNode(true);
16852
+ Array.prototype.forEach.call(c.querySelectorAll('svg.anno-inline'),
16853
+ function (o) { if (o.parentNode) o.parentNode.removeChild(o); });
16854
+ Array.prototype.forEach.call(c.querySelectorAll('.anno-inline-wrap'),
16855
+ function (w) {
16856
+ const parent = w.parentNode;
16857
+ if (!parent) return;
16858
+ while (w.firstChild) parent.insertBefore(w.firstChild, w);
16859
+ parent.removeChild(w);
16860
+ });
16861
+ return c.innerHTML;
16862
+ }
16863
+ // Identity of what was BAKED into one `.drawio` box. `hidden` is excluded on
16864
+ // purpose (page selection is view state this function must not react to),
16865
+ // and so is the page-navigation node the init hook appends — it is a child
16866
+ // of the box, never of a `.drawio-page`. A failed bake has no pages, so its
16867
+ // rendered error text is its identity instead.
16868
+ function drawioFingerprint(box) {
16869
+ if (!box) return null;
16870
+ const pages = box.querySelectorAll('.drawio-page');
16871
+ if (!pages.length) return 'raw' + DRAWIO_FP_SEP + box.innerHTML;
16872
+ const out = ['pages' + DRAWIO_FP_SEP + (box.getAttribute('data-drawio-pages') || '')];
16873
+ for (let i = 0; i < pages.length; i++) out.push(drawioPageMarkup(pages[i]));
16874
+ return out.join(DRAWIO_FP_SEP);
16875
+ }
16876
+ // Identity of every diagram in one block. The count leads, so a block that
16877
+ // gained or lost a box can never collide with one that did not.
16878
+ function drawioBlockFingerprint(boxes) {
16879
+ const out = [String(boxes.length)];
16880
+ for (let i = 0; i < boxes.length; i++) out.push(drawioFingerprint(boxes[i]));
16881
+ return out.join(DRAWIO_FP_SEP + DRAWIO_FP_SEP);
16882
+ }
16883
+
16884
+ let drawioRefreshInFlight = false;
16885
+ // Fix round 1 (review F2): the token from the last refresh that ACTUALLY
16886
+ // landed in the DOM, to be handed back on the next heartbeat. The server
16887
+ // advances that tab's staleness baseline on this and on nothing else, so
16888
+ // every bail path below — by simply not setting it — is genuinely retried
16889
+ // on the next tick instead of losing the change for the rest of the
16890
+ // session.
16891
+ let drawioAckPending = null;
16892
+
16893
+ // ── Fix round 2 (re-review G1b): a persistent bail must not cost a bake ──
16894
+ //
16895
+ // The three pre-fetch bails below make the long-lived conditions free, and
16896
+ // G1a removes the one post-fetch bail whose condition the pre-fetch side
16897
+ // could not see. What this backs up is everything that cannot be enumerated
16898
+ // in advance: /api/render answering 500 deterministically, a server whose
16899
+ // ref list flaps between two tabs, a future bail nobody thought about here.
16900
+ // Any of those would otherwise re-issue a full render + Chromium bake on
16901
+ // every 10s beat for the life of the session.
16902
+ //
16903
+ // Only a bail that already PAID for a fetch counts. A pre-fetch bail is free
16904
+ // and must stay instant, or a reader who simply rested their caret in a
16905
+ // diagram block would be punished with minutes of latency once they moved.
16906
+ //
16907
+ // The update is delayed, never dropped: the server keeps re-reporting the
16908
+ // same staleness (F2's invariant is untouched), and the next attempt after
16909
+ // the blocking condition clears applies it. DOWNSIDE, stated plainly: after
16910
+ // a run of paid bails, a refresh that WOULD now succeed can wait up to
16911
+ // DRAWIO_BACKOFF_CAP_MS before it is tried. That is the price of bounding
16912
+ // the bake rate to once per that interval instead of once per beat.
16913
+ const DRAWIO_BACKOFF_BASE_MS = 10000;
16914
+ const DRAWIO_BACKOFF_CAP_MS = 160000;
16915
+ const DRAWIO_FETCH_TIMEOUT_MS = 120000;
16916
+ let drawioBailStreak = 0;
16917
+ let drawioNextAttemptAt = 0;
16918
+
16919
+ // Fix round 2 (re-review G6): the deferred notice now carries the moment it
16920
+ // was raised. A banner that surfaces minutes late describes a landing the
16921
+ // reader has long since navigated away from — worse than no banner, and the
16922
+ // same "teaches the user to ignore it" failure F6 was about.
16923
+ const DRAWIO_NOTICE_TTL_MS = 60000;
16924
+ // Fix round 1 (review F5): a notice this background path wants to show but
16925
+ // must not show YET. showBanner() is destructive by construction — its
16926
+ // first statement removes whatever banner is up — and every other caller in
16927
+ // this file is a response to a user gesture. This one fires from a timer,
16928
+ // and the banner it would remove can be the disk-conflict one, which is the
16929
+ // user's only route to resolving a conflict on the one data-safety-critical
16930
+ // path this editor has (it carries the Reload button). So the notice waits
16931
+ // for an empty slot instead of taking one.
16932
+ let drawioDeferredNotice = null;
16933
+ // showBanner() also tears down these four overlays unconditionally. That is
16934
+ // right for a gesture-driven caller and wrong for a timer: firing it while
16935
+ // the reader has a grip, an insert bubble, the table edge menu or the H▾
16936
+ // dropdown open closes it under them at a moment nothing on screen
16937
+ // explains. So the flush waits for those too (re-review G6).
16938
+ function noticeTeardownWouldDisrupt() {
16939
+ if (toolbarMenuEl) return true;
16940
+ if (teEdgeMenu && !teEdgeMenu.hidden) return true;
16941
+ if (rowGrip && !rowGrip.hidden) return true;
16942
+ if (colGrip && !colGrip.hidden) return true;
16943
+ if (rowInsertBubble && !rowInsertBubble.hidden) return true;
16944
+ if (colInsertBubble && !colInsertBubble.hidden) return true;
16945
+ return false;
16946
+ }
16947
+ function noteDrawioNotice(message) {
16948
+ if (activeBanner || noticeTeardownWouldDisrupt()) {
16949
+ drawioDeferredNotice = { message: message, at: Date.now() };
16950
+ return;
16951
+ }
16952
+ drawioDeferredNotice = null;
16953
+ showBanner(message, null, null);
16954
+ }
16955
+ function flushDrawioNotice() {
16956
+ if (!drawioDeferredNotice) return;
16957
+ if (Date.now() - drawioDeferredNotice.at > DRAWIO_NOTICE_TTL_MS) {
16958
+ drawioDeferredNotice = null; // stale: it no longer describes the page
16959
+ return;
16960
+ }
16961
+ if (activeBanner || noticeTeardownWouldDisrupt()) return;
16962
+ const m = drawioDeferredNotice.message;
16963
+ drawioDeferredNotice = null;
16964
+ showBanner(m, null, null);
16965
+ }
16966
+ const DRAWIO_PAGE_GONE_MSG =
16967
+ '目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
16968
+ // Fix round 3 (re-review2 H3 / Q1 ruling). Annotations are reading-session
16969
+ // scratch — lib/md2doc.js keeps them in a WeakMap keyed by the source node
16970
+ // and says so ("In-memory only — gone on reload"; nothing writes them to
16971
+ // localStorage or to disk) — but they are still the reader's own work, and
16972
+ // a re-bake changes the geometry they were drawn over, so carrying them
16973
+ // across would place marks on shapes that moved. They are dropped, and the
16974
+ // reader is told which diagram lost them and why.
16975
+ const DRAWIO_ANNO_DROPPED_MSG =
16976
+ '這張 drawio 圖在磁碟上被改過,已重新繪製;畫在舊版上的註記無法對應到新的圖形,已移除。';
16977
+
16978
+ // What "never yank focus out from under the user" actually has to mean here.
16979
+ //
16980
+ // An EDITING SURFACE — a contenteditable, a raw-source textarea, an input —
16981
+ // is something the user is working IN; replacing the node under it destroys
16982
+ // work in progress and is never acceptable for a refresh nobody asked for.
16983
+ // Anything else that merely happens to hold focus is not: MEASURED — a
16984
+ // click on the multi-page navigation leaves that <button> as
16985
+ // document.activeElement, and it sits inside the very block a re-bake
16986
+ // replaces. Treating that as "the user is busy here" means the reader who
16987
+ // just picked a page, alt-tabbed to Draw.io and edited the file comes back
16988
+ // to a diagram that (with fix round 1's retry in place) refuses to update
16989
+ // for the whole session instead of losing the update once. So the guard is
16990
+ // scoped to surfaces, and a focused non-surface is allowed to be replaced.
16991
+ function focusIsEditingSurface(node) {
16992
+ if (!node) return false;
16993
+ if (node.isContentEditable) return true;
16994
+ const tag = node.tagName;
16995
+ return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT';
16996
+ }
16997
+
16998
+ // Is the user editing inside a block that carries a diagram we may be about
16999
+ // to replace? Checked BEFORE the fetch as well as per block after it: with
17000
+ // F2's retry in place an unconditional fetch would otherwise re-run a full
17001
+ // headless-Chromium bake every 10 seconds for as long as that editor stays
17002
+ // open. G1a is what makes this pre-check and the per-block skip agree: both
17003
+ // now ask drawioBoxIn() about the same live node.
17004
+ function drawioBlockHoldsEditingFocus() {
17005
+ const ae = document.activeElement;
17006
+ if (!ae || !contentEl.contains(ae)) return false;
17007
+ if (!focusIsEditingSurface(ae)) return false;
17008
+ const kids = contentEl.children;
17009
+ for (let i = 0; i < kids.length; i++) {
17010
+ if (!kids[i].contains(ae)) continue;
17011
+ return !!drawioBoxIn(kids[i]);
17012
+ }
17013
+ return false;
17014
+ }
17015
+
17016
+ async function refreshStaleDrawio(token) {
17017
+ if (drawioRefreshInFlight) return;
17018
+ // Review question Q2 / G1b: the cheap "there is nothing on screen this
17019
+ // could possibly change" and "the user is in the middle of something"
17020
+ // answers are given BEFORE the fetch, so a condition that can hold
17021
+ // indefinitely never costs a bake. None of them acks — nothing was
17022
+ // applied, so the next tick must ask again.
17023
+ if (!contentEl.querySelector('.drawio')) return;
17024
+ if (!domMatchesLastRender()) return;
17025
+ if (drawioBlockHoldsEditingFocus()) return;
17026
+ // Fix round 2 (re-review G7): a block drag in flight addresses every block
17027
+ // by ordinal across .content. applyPatch()/applyFullRender() cancel it
17028
+ // because they are gesture-driven and the gesture is already over; a
17029
+ // background timer has no such licence, so it waits the drag out instead.
17030
+ // Free and self-clearing — a drag is a pointer gesture, it cannot last.
17031
+ if (blockDragState) return;
17032
+ // Fix round 3 (re-review2 H1): a `.drawio` inside a table cell makes the
17033
+ // whole TABLE block replaceable by this path (verified by render — round
17034
+ // 2's report claimed the opposite). A live row/column pointer gesture
17035
+ // addresses rows and cells inside that table, so the refresh waits it out
17036
+ // rather than cancelling it from a timer — the same construction, and the
17037
+ // same reasoning, as `blockDragState` just above.
17038
+ if (tePointer) return;
17039
+ if (Date.now() < drawioNextAttemptAt) return;
17040
+ drawioRefreshInFlight = true;
17041
+ // Fix round 2 (re-review G8): without a deadline, one request that never
17042
+ // settles (a server wedged inside a bake — bakeDrawioSvg() has no
17043
+ // per-page timeout of its own) leaves this flag true forever, every later
17044
+ // {stale:true} returns at the line above, and the whole feature is
17045
+ // silently and permanently dead until the user reloads, with no signal at
17046
+ // all. An abort turns that into an ordinary paid bail: backed off, retried.
17047
+ const ctl = (typeof AbortController === 'function') ? new AbortController() : null;
17048
+ const abortTimer = ctl
17049
+ ? setTimeout(function () { try { ctl.abort(); } catch (e) { /* already settled */ } },
17050
+ DRAWIO_FETCH_TIMEOUT_MS)
17051
+ : null;
17052
+ let paidForAFetch = false;
17053
+ let acked = false;
17054
+ try {
17055
+ const startContent = lines.join('\n');
17056
+ let res;
17057
+ paidForAFetch = true;
17058
+ try {
17059
+ const init = {
17060
+ method: 'POST', headers: { 'content-type': 'application/json' },
17061
+ body: JSON.stringify({ fileId: ED.fileId, content: startContent }),
17062
+ };
17063
+ if (ctl) init.signal = ctl.signal;
17064
+ res = await fetch('/api/render', init);
17065
+ } catch (e) { return; }
17066
+ if (!res.ok) return;
17067
+ let j;
17068
+ try { j = await res.json(); } catch (e) { return; }
17069
+ if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) return;
17070
+ // A real edit may have landed (and its own rerenderAll() already read
17071
+ // the file fresh) while this request was in flight — `lines` no
17072
+ // longer matches what this response was computed from, so there is
17073
+ // nothing left to apply; the document already shows current content.
17074
+ if (lines.join('\n') !== startContent) return;
17075
+ if (!Array.isArray(lastParts) || lastParts.length !== j.parts.length ||
17076
+ !Array.isArray(blocks) || blocks.length !== j.blocks.length) return;
17077
+ // Same precondition applyPatch()'s own plan requires (see
17078
+ // domMatchesLastRender()'s comment): several list gestures mutate
17079
+ // .content's child list directly, between renders, without going
17080
+ // through a commit yet. If the DOM does not positionally match
17081
+ // `lastParts` right now, a mid-gesture edit is in flight — bail rather
17082
+ // than swap the wrong child. Re-read after the await, because the
17083
+ // pre-fetch check above cannot speak for the time the render took; same
17084
+ // for the block drag.
17085
+ if (!domMatchesLastRender()) return;
17086
+ if (blockDragState) return;
17087
+ if (tePointer) return;
17088
+ // Every block this refresh was responsible for has to land before the
17089
+ // server is told the tab is up to date. One skipped block means the
17090
+ // user is still looking at stale bytes, which is exactly the state F2
17091
+ // says must keep being retried.
17092
+ let applied = true;
17093
+ for (let i = 0; i < j.parts.length; i++) {
17094
+ const el = contentEl.children[i];
17095
+ const oldBoxes = drawioBoxesIn(el);
17096
+ // The prefilter is a superset test and decides nothing: it only says
17097
+ // "parsing this part might be worth it".
17098
+ if (!oldBoxes.length && !DRAWIO_TEXT_HINT.test(j.parts[i])) continue;
17099
+ const tpl = parsePartMarkup(j.parts[i]);
17100
+ const newBoxes = drawioBoxesIn(tpl.content);
17101
+ // Neither side holds a real diagram — the hint matched text that only
17102
+ // quotes this markup (a fenced code block, a table cell). Not a
17103
+ // candidate for anything, by either criterion.
17104
+ if (!oldBoxes.length && !newBoxes.length) continue;
17105
+ // Every diagram in the block is compared, and the bake is identical:
17106
+ // nothing to do. This is what keeps one external write from re-swapping
17107
+ // every OTHER diagram on the page (G4) — while H2 is why it has to be
17108
+ // every box and not just the first.
17109
+ if (drawioBlockFingerprint(oldBoxes) === drawioBlockFingerprint(newBoxes)) continue;
17110
+
17111
+ if (!el) { applied = false; continue; }
17112
+ // Never destroy an editor the user has open in this block — see
17113
+ // focusIsEditingSurface() for why it is surfaces and not "anything
17114
+ // focused". Not applied, so not acked: the next tick tries again, and
17115
+ // because the pre-fetch guard asks drawioBoxIn() about the same live
17116
+ // node, that next tick bails BEFORE paying for a render.
17117
+ if (el.contains(document.activeElement) && focusIsEditingSurface(document.activeElement)) {
17118
+ applied = false; continue;
17119
+ }
17120
+ // Fix round 2 (re-review G7): `gutterMenu` and `insertMenu` are ONE
17121
+ // shared node each, appended as a CHILD of whichever block they are
17122
+ // open for. Replacing that block detaches the singleton with no close
17123
+ // path run, leaving `gutterMenuBlockEl`/`insertMenuBlockEl` pointing
17124
+ // at a node no future click can reach — the exact hazard
17125
+ // applyPatch()/applyFullRender() call these two for. Scoped to the
17126
+ // block actually being replaced rather than called unconditionally
17127
+ // the way those two do, and the reason is the one F5 turns on: they
17128
+ // run on a user gesture that is already over, this runs on a 10s
17129
+ // timer, and closing a menu the reader has open on an UNRELATED block
17130
+ // would be a fresh instance of exactly the defect F5 closed.
17131
+ if (gutterMenuBlockEl === el) closeGutterMenu();
17132
+ if (insertMenuBlockEl === el) closeInsertMenu();
17133
+ // Fix round 3 (re-review2 H1). Round 2's report said 「一個 drawio 區塊
17134
+ // 永遠不是表格」 and refused the table resets on that basis. The claim is
17135
+ // FALSE — rendering `| ![d](d.drawio) | x |` yields a
17136
+ // `data-block-type="table"` block whose first cell holds the box, so
17137
+ // this path really can replace a whole table. The grips and the edge
17138
+ // menu live on document.body, so the swap does not detach them: they
17139
+ // stay visibly floating while `teMenuTableEl` / `teMenuRowEl` /
17140
+ // `gripRowTableEl` / `gripRowEl` / `gripColTableEl` point into a tree
17141
+ // nothing can reach. Scoped exactly like the two menus above, and for
17142
+ // the same reason. (A live row/column POINTER gesture is not handled
17143
+ // here at all — `if (tePointer) return;` above waits it out rather
17144
+ // than killing it from a timer, the same construction `blockDragState`
17145
+ // uses. The insert bubbles hold no element reference of their own —
17146
+ // hideTableInsertBubbles() only flips two `hidden` flags — so nothing
17147
+ // of theirs can dangle; the next mousemove re-derives them.)
17148
+ if ((teMenuTableEl && el.contains(teMenuTableEl)) ||
17149
+ (teMenuRowEl && el.contains(teMenuRowEl))) hideTableEdgeMenu();
17150
+ if ((gripRowTableEl && el.contains(gripRowTableEl)) ||
17151
+ (gripRowEl && el.contains(gripRowEl)) ||
17152
+ (gripColTableEl && el.contains(gripColTableEl))) hideTableGrips();
17153
+ // A non-surface node inside this block (the roving-tabindex holder of
17154
+ // a block selection, a page button just clicked) is about to stop
17155
+ // existing. Put focus back on the block wrapper afterwards when the
17156
+ // wrapper is a legitimate focus holder, so a keyboard user keeps
17157
+ // their place instead of being dropped at <body>.
17158
+ const refocusWrapper = el.contains(document.activeElement) && el.hasAttribute('tabindex');
17159
+
17160
+ // Ruling B2-2 (c): preserve the reader's currently-open page across
17161
+ // the swap, falling back to the first page with a visible banner when
17162
+ // it genuinely no longer exists. resolveDrawioLanding() (near the top
17163
+ // of this file, unit-tested) owns the "is a page name an identity
17164
+ // here at all" question — see its comment.
17165
+ //
17166
+ // Fix round 3 (re-review2 H2, second half): captured for EVERY box in
17167
+ // the block, not just the first, so a second multi-page diagram in one
17168
+ // block keeps the reader's page too.
17169
+ const oldLandings = oldBoxes.map(function (box) {
17170
+ if (!box.hasAttribute('data-drawio-pages')) return null;
17171
+ let names = [];
17172
+ try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
17173
+ catch (e2) { names = []; }
17174
+ if (!Array.isArray(names) || names.length < 2) return null;
17175
+ const pages = box.querySelectorAll('.drawio-page');
17176
+ const activePage = box.querySelector('.drawio-page:not([hidden])');
17177
+ return {
17178
+ names: names,
17179
+ activeIdx: activePage ? Array.prototype.indexOf.call(pages, activePage) : -1,
17180
+ };
17181
+ });
17182
+
17183
+ // Fix round 3 (re-review2 H3, Q1 ruling). The reader's annotations are
17184
+ // drawn INTO this block's `.drawio-page` and cannot be carried onto a
17185
+ // re-baked diagram whose geometry is different — but dropping the
17186
+ // user's own work silently is not something this codebase does. The
17187
+ // unrelated-write case never reaches here at all (drawioPageMarkup()
17188
+ // normalises the annotation subtree out of the fingerprint, so such a
17189
+ // block compares equal and is not swapped); what is left is the case
17190
+ // where the annotated diagram's OWN bytes changed, and that one is
17191
+ // told. Same principle as B2-P1: the two outcomes must not both be
17192
+ // silent.
17193
+ const hadAnnotations = !!(el.querySelector && el.querySelector('.anno-inline-wrap'));
17194
+
17195
+ const fresh = tpl.content.firstElementChild;
17196
+ if (!fresh) { applied = false; continue; }
17197
+ armEditables(tpl.content);
17198
+ // Best-effort carry-over of the wrapper's own visual state. Never
17199
+ // reaches into blockSelection / applySelectionClasses() here — that
17200
+ // call also moves focus, which a background refresh must never do.
17201
+ if (el.classList.contains('ed-selected')) fresh.classList.add('ed-selected');
17202
+ if (el.hasAttribute('tabindex')) fresh.setAttribute('tabindex', el.getAttribute('tabindex'));
17203
+
17204
+ el.replaceWith(fresh);
17205
+ lastParts[i] = j.parts[i];
17206
+ // preventScroll: this is a background refresh — .focus()'s default
17207
+ // scroll-into-view would yank the reader's viewport to a block they
17208
+ // merely left focused before scrolling away.
17209
+ if (refocusWrapper && fresh.focus) fresh.focus({ preventScroll: true });
17210
+ if (hadAnnotations) noteDrawioNotice(DRAWIO_ANNO_DROPPED_MSG);
17211
+
17212
+ // Fix round 1 (review F5 second half): the page landing and its
17213
+ // notice happen whether or not the page-navigation hook exists.
17214
+ // They used to sit INSIDE the hook check, so a document that was
17215
+ // single-page when the tab opened (the hook is gated on that, by a
17216
+ // byte-exact guard test) lost the page preservation AND the
17217
+ // "your page is gone" notice together — both branches of Ruling
17218
+ // B2-P1 going silent at once. Only the navigation rebuild itself
17219
+ // genuinely depends on the hook.
17220
+ if (window.__md2docInitDrawioSheetBar) {
17221
+ try { window.__md2docInitDrawioSheetBar(fresh); } catch (e4) { /* navigation only */ }
17222
+ }
17223
+ const freshBoxes = drawioBoxesIn(fresh);
17224
+ // Align old boxes to new ones BY POSITION, and only when the block
17225
+ // still holds the same number of diagrams. A block whose box count
17226
+ // changed has had one reference stop (or start) being bakeable, and
17227
+ // position no longer identifies anything — landing every box on the
17228
+ // baked default is then the only answer we can stand behind. Claiming
17229
+ // a page deletion there would be the same false-banner mistake F6 was
17230
+ // about, so that case is deliberately quiet.
17231
+ if (freshBoxes.length !== oldBoxes.length) continue;
17232
+ for (let b = 0; b < freshBoxes.length; b++) {
17233
+ const prev = oldLandings[b];
17234
+ if (!prev) continue; // was not a multi-page box
17235
+ const box = freshBoxes[b];
17236
+ if (!box.hasAttribute('data-drawio-pages')) continue;
17237
+ let newNames = [];
17238
+ try { newNames = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
17239
+ catch (e3) { newNames = []; }
17240
+ if (!Array.isArray(newNames)) newNames = [];
17241
+ const newPages = box.querySelectorAll('.drawio-page');
17242
+ const landing = resolveDrawioLanding(prev.names, prev.activeIdx, newNames);
17243
+ // Review Q1: clamp. `data-drawio-pages` and the .drawio-page children
17244
+ // are emitted together by lib/md2doc.js so they cannot disagree
17245
+ // today, but an out-of-range index here hides EVERY page and paints a
17246
+ // blank box — one line is cheaper than that failure.
17247
+ let landOn = landing.landOn;
17248
+ if (!(landOn >= 0) || landOn >= newPages.length) landOn = 0;
17249
+ for (let p = 0; p < newPages.length; p++) newPages[p].hidden = p !== landOn;
17250
+ const bar = box.querySelector('.drawio-sheetbar');
17251
+ if (bar) {
17252
+ bar.querySelectorAll('button').forEach(function (btn, bidx) {
17253
+ btn.setAttribute('aria-pressed', bidx === landOn ? 'true' : 'false');
17254
+ });
17255
+ }
17256
+ if (landing.gone) noteDrawioNotice(DRAWIO_PAGE_GONE_MSG);
17257
+ }
17258
+ }
17259
+ if (applied && typeof token === 'string' && token !== '') {
17260
+ drawioAckPending = token;
17261
+ acked = true;
17262
+ }
17263
+ } finally {
17264
+ if (abortTimer) clearTimeout(abortTimer);
17265
+ drawioRefreshInFlight = false;
17266
+ if (acked) {
17267
+ drawioBailStreak = 0;
17268
+ drawioNextAttemptAt = 0;
17269
+ } else if (paidForAFetch) {
17270
+ drawioBailStreak += 1;
17271
+ const wait = Math.min(DRAWIO_BACKOFF_BASE_MS * Math.pow(2, drawioBailStreak),
17272
+ DRAWIO_BACKOFF_CAP_MS);
17273
+ drawioNextAttemptAt = Date.now() + wait;
17274
+ }
17275
+ }
17276
+ }
17277
+
14940
17278
  setInterval(() => {
17279
+ // A notice this refresh could not show earlier because the slot was
17280
+ // taken (see noteDrawioNotice) gets its next chance here, on the same
17281
+ // clock — or is dropped if it has gone stale.
17282
+ flushDrawioNotice();
14941
17283
  // /api/ping requires content-type: application/json like the other
14942
17284
  // state-changing POST routes (415 otherwise) — see server.js's CORS
14943
17285
  // defense. A body is included so the header is meaningful, not just
14944
17286
  // present on an otherwise-empty request.
17287
+ //
17288
+ // v3.4.0 batch2 Task 6 (Ruling B2-2): `fileId` + this tab's own
17289
+ // `drawioClientId` let server.js answer whether any `.drawio`/`.xml`
17290
+ // this document references changed on disk since the bytes THIS TAB is
17291
+ // showing — see its own comment on /api/ping. A 204 (no body) means
17292
+ // "nothing stale", the same outcome this heartbeat always had; a 200
17293
+ // with `{stale:true, token}` is new and triggers a background refresh.
17294
+ // `drawioAck` carries back the token of the last refresh that actually
17295
+ // landed, and is the ONLY thing that advances this tab's baseline.
17296
+ // Every failure mode (network error, non-200/204 status, malformed
17297
+ // JSON) is swallowed exactly like the old bare ping already swallowed
17298
+ // everything — a missed signal just waits for the next 10s tick, which
17299
+ // is the accepted worst-case latency for this whole feature.
17300
+ const ack = drawioAckPending;
17301
+ drawioAckPending = null;
14945
17302
  fetch('/api/ping', {
14946
- method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
17303
+ method: 'POST', headers: { 'content-type': 'application/json' },
17304
+ body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
17305
+ }).then((res) => {
17306
+ if (res.status !== 200) return null;
17307
+ return res.json().catch(() => null);
17308
+ }).then((j) => {
17309
+ if (j && j.stale) refreshStaleDrawio(j.token).catch(() => {});
14947
17310
  }).catch(() => {});
14948
17311
  }, 10000);
14949
17312
 
14950
17313
  armEditables(contentEl);
14951
17314
  wireBlockSelection();
17315
+
17316
+ // ── v3.4.0 batch3 Task 6: the wavedrom editing surface ─────────────────
17317
+ //
17318
+ // Entry is a hover affordance over an already-rendered diagram, and the
17319
+ // affordance itself lives on document.body — not in .content. Everything in
17320
+ // .content is read back as this tab's own render (see `lastParts` above) and
17321
+ // the WYSIWYG path turns it into markdown, so a control parked in there would
17322
+ // travel into the user's file. The table grips already sit on body for the
17323
+ // same reason; this follows them rather than inventing a second policy.
17324
+ //
17325
+ // The block is NOT parsed here. Ruling P3-6 parses at Edit-click time, so a
17326
+ // document with forty diagrams pays nothing for thirty-nine of them, and a
17327
+ // block whose WaveJSON cannot be read still gets an editor — one that says
17328
+ // what the parser said and where — instead of a button that silently does
17329
+ // nothing.
17330
+ let waveDiagramEl = null;
17331
+ let waveEditor = null;
17332
+ // The seam Task 7 writes through: which block the open editor came from, and
17333
+ // the line range of the fence's BODY (the fence markers themselves are not
17334
+ // the editor's to rewrite). `lastGesture` is refreshed on every gesture, not
17335
+ // accumulated — wave-ui computes one patch per gesture precisely so a
17336
+ // write-back never has to replay a session's worth of them.
17337
+ //
17338
+ // Task 7's own fields, and why each is here rather than derived:
17339
+ // ops how many commits this session has pushed onto the document
17340
+ // stack. Escape reverses exactly this many — see
17341
+ // finishWaveSession().
17342
+ // baseLines `lines` as it stood when the editor opened, and
17343
+ // baseBlocks `blocks` likewise. Escape restores both BY VALUE instead of
17344
+ // trusting the reversal's arithmetic to land back where it
17345
+ // started; the two are the same answer, and one of them cannot
17346
+ // be off by a line.
17347
+ // baseSource the block body the store was opened over, i.e. what `lines`
17348
+ // holds again once an Escape has run. The stash's staleness
17349
+ // guard compares against it.
17350
+ // unwritten this session has a gesture the file could not express (the
17351
+ // document-level flag `waveUnwrittenEdit` is the same fact; the
17352
+ // flag is what documentIsDirty() reads, this is what the
17353
+ // session's own bookkeeping reads).
17354
+ // baseDepth how deep the document's undo stack was at open — the one
17355
+ // baseline both of waveDiscardIsSafe()'s questions are asked
17356
+ // against. See that function for the measured sequence that
17357
+ // killed the arithmetic it replaces (fix 2 / MUST-FIX 1).
17358
+ // baseRedoTail the redo branch that existed before the session (fix 1 /
17359
+ // F3). The session's first commit clears it like any other edit;
17360
+ // a session that is DISCARDED has to hand it back, or the user
17361
+ // silently loses a redo to a document that ends up
17362
+ // byte-identical.
17363
+ let waveSeam = null;
17364
+ // Gestures that arrived with no editor left to own them. Zero is the
17365
+ // invariant, not a tolerance: a paint that lands after the overlay came down
17366
+ // is one the user cancelled, applied to a store nobody can see. It used to be
17367
+ // reachable — an Escape pressed mid-drag left the drag armed and the document
17368
+ // listeners attached, so the eventual release still ran the paint. Counted
17369
+ // rather than merely guarded against, because "it cannot happen" is what the
17370
+ // last version of this said.
17371
+ let waveStrayGestures = 0;
17372
+ const waveEditBtn = document.createElement('button');
17373
+ waveEditBtn.type = 'button';
17374
+ waveEditBtn.className = 'ed-wave-edit-btn';
17375
+ waveEditBtn.textContent = '編輯波形';
17376
+ waveEditBtn.title = '用圖形介面編輯這張時序圖';
17377
+ waveEditBtn.hidden = true;
17378
+ document.body.appendChild(waveEditBtn);
17379
+
17380
+ /**
17381
+ * Whether the wave editor is the modal surface right now.
17382
+ *
17383
+ * The one question every global handler in this file asks about it, and it is
17384
+ * deliberately a question about STATE and not about the event.
17385
+ *
17386
+ * The previous version asked "did this event land inside the overlay?", and
17387
+ * that cannot answer "who owns the screen" — the answer has to be true before
17388
+ * any event arrives. MEASURED: right after the overlay opens,
17389
+ * `document.activeElement` is `document.body`, so every key targeted body and
17390
+ * the editor behind the modal still owned it. Three things fell out of that
17391
+ * one wrong question: Ctrl+Z in the unreadable panel rolled back the markdown
17392
+ * document behind the modal, `Tab` was swallowed by the "nothing focused"
17393
+ * branch so none of the dialog's controls could be reached by keyboard, and
17394
+ * the block-selection delete path stayed live for a body target.
17395
+ *
17396
+ * A target test also means a LIST — one `closest()` per event someone thought
17397
+ * of — and `drop` was the one nobody thought of: a PNG dragged onto the panel
17398
+ * went into the document behind it. A state question has no list.
17399
+ *
17400
+ * A function declaration on purpose: it is hoisted, so the listeners
17401
+ * registered far above can call it, and `waveEditor` is a `let` in this same
17402
+ * scope that is initialised long before any event fires.
17403
+ */
17404
+ function waveEditorIsModal() {
17405
+ return waveEditor !== null;
17406
+ }
17407
+
17408
+ /**
17409
+ * The one gesture the modal does not get to swallow.
17410
+ *
17411
+ * Kept beside the predicate rather than inlined at the call site, because
17412
+ * "which gestures the modal owns" is part of the same answer as "who owns the
17413
+ * screen", and the two belong in one place. Spelled exactly as the save branch
17414
+ * — which is ~1230 lines ABOVE this, in the main `keydown` listener (measured:
17415
+ * this declaration is at 16989, that branch at 15732) — so the two cannot drift
17416
+ * into disagreeing about what a save keystroke is. The direction matters: a
17417
+ * reader who goes looking DOWNWARD finds only the wave wiring, concludes this
17418
+ * comment is stale, and edits one predicate without the other, which is exactly
17419
+ * how the modal starts swallowing Ctrl+S again.
17420
+ *
17421
+ * ── Why this is the only exemption, as of v3.4.0 batch3 ─────────────────
17422
+ *
17423
+ * The question was asked of every branch of that listener rather than answered
17424
+ * for Ctrl+S alone, and the answer is recorded here so the next person adding
17425
+ * a global shortcut can see that it was asked:
17426
+ *
17427
+ * - Everything about EDITING the document behind the modal — the toolbar's
17428
+ * keyboard mode, block selection's Tab/Delete/Backspace, the burst
17429
+ * short-circuits, undo/redo, Escape — is exactly what the modal is for.
17430
+ * Those stay swallowed.
17431
+ * - Escape, Tab and undo/redo are additionally CLAIMED by the overlay's own
17432
+ * capture-phase handler, which stops them before they reach here, so the
17433
+ * gate is their second line and not their first.
17434
+ * - `Ctrl+P` / `Ctrl+F` / `Ctrl+A` / `F5` have no handler in this file at
17435
+ * all, so the gate cannot swallow them: it is a bare `return` with no
17436
+ * `preventDefault()`, and the browser's own behaviour is untouched.
17437
+ * - `Ctrl+S` is the only global gesture this file implements that is not
17438
+ * about the covered document. It is the reflex that protects work, the
17439
+ * wave editor has nothing of its own to save in this task, and it is the
17440
+ * only producer of the 409 that raises the disk-conflict banner.
17441
+ *
17442
+ * So: a new global shortcut added below the gate is DEAD while the overlay is
17443
+ * up. If it is about the document, that is correct. If it is not — a help
17444
+ * panel, a command palette, anything the user reaches for regardless of what
17445
+ * is on screen — it belongs in this function.
17446
+ */
17447
+ function isSaveGesture(e) {
17448
+ return (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's';
17449
+ }
17450
+
17451
+ function hideWaveEditBtn() {
17452
+ // Already hidden is the overwhelmingly common case: this is called from a
17453
+ // CAPTURE-phase `mouseover` on the document (once per element the pointer
17454
+ // crosses) and a capture-phase `scroll` on the window (inner scrollers
17455
+ // included). Writing `hidden` unconditionally made every pointer move over
17456
+ // the page a DOM write on document.body's own subtree; the early return
17457
+ // makes both free.
17458
+ if (waveDiagramEl === null && waveEditBtn.hidden) return;
17459
+ waveDiagramEl = null;
17460
+ waveEditBtn.hidden = true;
17461
+ }
17462
+
17463
+ function showWaveEditBtnFor(el) {
17464
+ // Same diagram, already showing: nothing to write. The listener that calls
17465
+ // this fires once per element the pointer crosses, and a diagram is a tree
17466
+ // of them.
17467
+ if (waveDiagramEl === el && !waveEditBtn.hidden) return;
17468
+ const r = el.getBoundingClientRect();
17469
+ if (r.width === 0 && r.height === 0) return hideWaveEditBtn();
17470
+ waveDiagramEl = el;
17471
+ waveEditBtn.hidden = false;
17472
+ // Top-right of the diagram, nudged inside it so it never hangs over the
17473
+ // block gutter on the left or off the viewport on the right — and never
17474
+ // above the toolbar's own band, which would put this button UNDER the bar
17475
+ // (z-index 12 against 101) at every scroll position where the diagram's
17476
+ // top edge has gone past it. See `toolbarTopInset()` for the measurement:
17477
+ // the affordance was drawn, was invisible, was not clickable, and was the
17478
+ // feature's only entry point.
17479
+ waveEditBtn.style.top =
17480
+ Math.max(toolbarTopInset() + 4, r.top + 6) + 'px';
17481
+ waveEditBtn.style.left = Math.max(4,
17482
+ Math.min(r.right - waveEditBtn.offsetWidth - 6,
17483
+ window.innerWidth - waveEditBtn.offsetWidth - 6)) + 'px';
17484
+ }
17485
+
17486
+ // The class the diagram init hook tags every rendered display div with; the
17487
+ // id prefix is the same signal for a page that predates the class.
17488
+ const WAVE_DIAGRAM_SEL = '.wavedrom-diagram, [id^="WaveDrom_Display_"]';
17489
+
17490
+ function waveDiagramAt(node) {
17491
+ if (node === null || node === undefined || typeof node.closest !== 'function') return null;
17492
+ return node.closest(WAVE_DIAGRAM_SEL);
17493
+ }
17494
+
17495
+ /**
17496
+ * The rendered diagram INSIDE a block, if it holds one.
17497
+ *
17498
+ * `waveDiagramAt()` answers the pointer's question — "what is under this
17499
+ * element?" — by walking UP. The keyboard's question is the other direction:
17500
+ * the user is standing on a whole block and wants to know whether it is a
17501
+ * waveform. Same selector, deliberately shared, so the affordance a mouse
17502
+ * user gets and the one a keyboard user gets can never come to disagree
17503
+ * about which blocks are editable.
17504
+ *
17505
+ * A function declaration rather than an arrow const: the keydown listener
17506
+ * that calls it is registered hundreds of lines above this point.
17507
+ */
17508
+ function waveDiagramInside(blockEl) {
17509
+ if (blockEl === null || blockEl === undefined ||
17510
+ typeof blockEl.querySelector !== 'function') return null;
17511
+ return blockEl.querySelector(WAVE_DIAGRAM_SEL);
17512
+ }
17513
+
17514
+ document.addEventListener('mouseover', (ev) => {
17515
+ if (waveEditor !== null) return;
17516
+ const t = ev.target;
17517
+ if (t !== null && typeof t.closest === 'function' && t.closest('.ed-wave-edit-btn')) return;
17518
+ const diagram = waveDiagramAt(t);
17519
+ if (diagram === null) { hideWaveEditBtn(); return; }
17520
+ showWaveEditBtnFor(diagram);
17521
+ }, true);
17522
+ window.addEventListener('scroll', () => { if (waveEditor === null) hideWaveEditBtn(); }, true);
17523
+
17524
+ // The fence BODY of one block: its source lines with the opening fence, and
17525
+ // the closing fence when there is one, taken off. An unterminated fence at the
17526
+ // end of the document has no closing line to drop, and dropping the last line
17527
+ // anyway would silently eat a cycle of the user's diagram.
17528
+ function waveBodyOf(block) {
17529
+ const src = lines.slice(block.startLine - 1, block.endLine);
17530
+ let end = src.length;
17531
+ if (end > 1 && /^\s*(?:```+|~~~+)\s*$/.test(src[end - 1])) end--;
17532
+ return {
17533
+ text: src.slice(1, end).join('\n'),
17534
+ startLine: block.startLine + 1,
17535
+ endLine: block.startLine + end - 1,
17536
+ };
17537
+ }
17538
+
17539
+ async function openWaveEditor(diagram) {
17540
+ const ui = window.md2docWaveUi;
17541
+ const mods = window.__md2docWave;
17542
+ // An older cached page that predates the injection must degrade to "the
17543
+ // button does nothing visible", never to a throw out of a click handler.
17544
+ if (!ui || !mods || !mods['wave-store.js'] || !mods['wave-codec.js'] ||
17545
+ !mods['wave-geometry.js']) return;
17546
+ if (waveEditor !== null) return;
17547
+ hideWaveEditBtn();
17548
+
17549
+ // Settle the document BEFORE reading a single line out of it. An open
17550
+ // burst or block editor is committed here, deliberately in front of the
17551
+ // overlay rather than behind it: the commit rewrites `lines` and can
17552
+ // re-render `.content`, and a line range read before that is stale the
17553
+ // moment it is written. A standing block selection goes too — the wave
17554
+ // editor is about to take the keyboard, and a selection the user can no
17555
+ // longer see is a selection they cannot mean.
17556
+ // fix 2 (R2): the return value is not decoration. When it is false an editor
17557
+ // is still open BEHIND the overlay, and the session's own Ctrl+S — which
17558
+ // runs `switchAwayFrom().then(ok => ok && save())` — then commits it,
17559
+ // pushing a NON-wave op inside the session. That op breaks both of the
17560
+ // session's bookkeeping assumptions at once: `seam.ops` no longer counts the
17561
+ // top of the stack (so a discard would reverse somebody else's edit — now
17562
+ // caught by waveDiscardIsSafe()'s depth question), and its line delta would
17563
+ // be attributed to the wave block by settleWaveBlocks(), which is F2's own
17564
+ // failure shape through a door F2 did not close. Every other caller in this
17565
+ // file already treats false as "do not proceed"; so does this one.
17566
+ // Was the keyboard standing ON this block when the editor opened? Asked
17567
+ // BEFORE `switchAwayFrom()`, because that can re-render `.content` and
17568
+ // detach the very elements the question is about.
17569
+ //
17570
+ // A state question, not a「which device opened it」one: the keyboard entry
17571
+ // above always satisfies it, and so does a mouse user who selected the
17572
+ // block first and then pressed the affordance — in both cases the editor
17573
+ // is about to take a keyboard that was somewhere visible, and closing owes
17574
+ // it back. When it is false (the ordinary hover-and-click), the keyboard
17575
+ // was on `document.body` before the overlay opened and this changes
17576
+ // nothing about where it ends up.
17577
+ const cameFromBlockSelection = blockSelection !== null &&
17578
+ typeof diagram.closest === 'function' &&
17579
+ selectionFocusBlockEl() === diagram.closest('.ed-block');
17580
+ if (!(await switchAwayFrom())) return;
17581
+ clearBlockSelection();
17582
+
17583
+ // The re-render `switchAwayFrom()` may have run replaces `.content`
17584
+ // wholesale, so the element that was hovered can be detached and its block
17585
+ // id can have shifted. Re-resolve both, and give up quietly if the diagram
17586
+ // is no longer on screen — the user hovers again, which is cheaper than
17587
+ // opening an editor over a block that is not the one they pointed at.
17588
+ if (!document.contains(diagram)) return;
17589
+ const blockEl = diagram.closest('.ed-block');
17590
+ if (blockEl === null) return;
17591
+ const id = Number(blockEl.getAttribute('data-block-id'));
17592
+ const block = blocks.find((b) => b.id === id);
17593
+ if (!block) return;
17594
+ const body = waveBodyOf(block);
17595
+
17596
+ // The seam is written only once the editor really exists. createWaveEditor
17597
+ // can throw (the `.content` mount guard is a throw by design), and a seam
17598
+ // left standing with no editor behind it is a lie the next gesture would
17599
+ // read.
17600
+ let editor = null;
17601
+ try {
17602
+ editor = ui.createWaveEditor({
17603
+ document: document,
17604
+ host: document.body,
17605
+ source: body.text,
17606
+ codec: mods['wave-codec.js'],
17607
+ geometry: mods['wave-geometry.js'],
17608
+ createStore: mods['wave-store.js'].createStore,
17609
+ // The store's own answer to「哪一條 lane 是哪一條」across one operation,
17610
+ // handed over rather than re-derived: the editor's cell cursor and its
17611
+ // selection are row indexes, and the patch planner reads the identical
17612
+ // function to decide which lane moved. Two implementations of that
17613
+ // question would let a mark paint one lane while the patch wrote
17614
+ // another.
17615
+ laneTrace: mods['wave-store.js'].laneTrace,
17616
+ wavedrom: (typeof WaveDrom !== 'undefined') ? WaveDrom : null,
17617
+ onGesture: (info) => {
17618
+ if (waveSeam === null) { waveStrayGestures++; return; }
17619
+ waveSeam.lastGesture = info;
17620
+ writeWaveGestureBack(info);
17621
+ },
17622
+ onClose: (reason) => {
17623
+ // Read the seam out and null BOTH before doing anything slow: the
17624
+ // predicate every global listener asks (`waveEditorIsModal()`) has to
17625
+ // answer "no" from this instant, and finishWaveSession() below awaits
17626
+ // a render. A seam still standing across that await is one a stray
17627
+ // late gesture could write through.
17628
+ const seam = waveSeam;
17629
+ waveEditor = null;
17630
+ waveSeam = null;
17631
+ finishWaveSession(seam, reason)
17632
+ // …and only then the keyboard, because the commit path RE-RENDERS
17633
+ // `.content` and every block element that existed when the overlay
17634
+ // was up is gone by the time that resolves. Focusing before the
17635
+ // render would put the cursor on a detached node — which is
17636
+ // `document.body` with extra steps.
17637
+ .then(() => { giveKeyboardBackToWaveBlock(seam); })
17638
+ .catch((err) => {
17639
+ console.error('md2doc: the wave editor could not settle its ' +
17640
+ 'write-back', err);
17641
+ });
17642
+ },
17643
+ });
17644
+ } catch (err) {
17645
+ waveEditor = null;
17646
+ waveSeam = null;
17647
+ throw err;
17648
+ }
17649
+ waveEditor = editor;
17650
+ waveSeam = { blockId: id, startLine: body.startLine, endLine: body.endLine,
17651
+ lastGesture: null, ops: 0, unwritten: false,
17652
+ blockStartLine: block.startLine, blockEndLine: block.endLine,
17653
+ baseSource: body.text, baseLines: lines, baseBlocks: blocks,
17654
+ baseDepth: stack.depth, baseRedoTail: stack.redoTail(),
17655
+ reselect: cameFromBlockSelection };
17656
+ // A fresh session cannot inherit the previous one's unwritten work: that
17657
+ // session is over, and whatever it could not express went with it.
17658
+ waveUnwrittenEdit = false;
17659
+ }
17660
+
17661
+ // ── Task 7: one gesture, one write-back ────────────────────────────────
17662
+ //
17663
+ // Not one write-back per SESSION. Task 5 measured the store's refusal rate
17664
+ // climbing with the number of structural operations stacked up before a
17665
+ // patch is asked for — 8.0% at 1-3 operations, 17.0% at 1-6, and an
17666
+ // independently generated corpus agreed on the shape (6.6% / 18.8%). Those
17667
+ // refusals are correct; they are what replaced a class of silent corruption.
17668
+ // But a refusal the user meets at the END of a session is one they have to
17669
+ // bisect out of their own work, so the patch is computed and applied on the
17670
+ // gesture that caused it. wave-ui already computes exactly one patch per
17671
+ // gesture for this reason; this is the other end of that seam.
17672
+ //
17673
+ // Everything here goes through commitRangeEdit() — the same helper every
17674
+ // other structural gesture in this file commits through — so the write-back
17675
+ // lands on the document undo stack like any other edit. `lines` is never
17676
+ // written directly. Two things fall out of that for free and are load-
17677
+ // bearing, not incidental: `stack.dirtyDepth` counts the waveform (so the
17678
+ // title dot, the save button and the beforeunload guard all see it without
17679
+ // knowing what a waveform is), and Escape can take the session back off the
17680
+ // stack rather than needing a stash of its own for the part that committed.
17681
+ //
17682
+ // What is deliberately NOT done here: re-rendering `.content`. The overlay
17683
+ // covers the document, a render replaces `.content` wholesale, and doing it
17684
+ // per paint would tear down and re-run WaveDrom on every gesture for a
17685
+ // picture nobody can see. `.content` therefore lags `lines` for exactly as
17686
+ // long as the modal is up, and the session's close settles it — a commit by
17687
+ // rendering, an Escape by putting `lines` back to what `.content` is still
17688
+ // showing.
17689
+ function writeWaveGestureBack(info) {
17690
+ const seam = waveSeam;
17691
+ if (seam === null || !info || !info.patch) return;
17692
+ if (info.patch.ok !== true) {
17693
+ waveRefuseGesture(seam, info.patch.reason);
17694
+ return;
17695
+ }
17696
+ const result = commitRangeEdit({ lines, blocks, stack },
17697
+ seam.startLine, seam.endLine, info.patch.text);
17698
+ if (result.op === null) {
17699
+ // `commitRangeEdit()` returns `op: null` for two different reasons, and
17700
+ // only one of them is "nothing to do": text that is already
17701
+ // byte-identical, and — tagged `refused` — a line range it will not touch
17702
+ // at all (an inverted one, i.e. a block that owns no source line).
17703
+ //
17704
+ // The `refused` arm is DEFENSIVE and is not known to be reachable from
17705
+ // here (fix 2 / R3, correcting an earlier claim that it was): the only
17706
+ // inverted seam this path can build comes from `waveBodyOf()` on an empty
17707
+ // fence body, and an empty wavedrom fence renders no `.wavedrom-diagram`,
17708
+ // so there is no hover affordance and no session. It is handled anyway
17709
+ // because the cost of being wrong about that is the silent drop this
17710
+ // whole layer exists to prevent, and the cost of the branch is three
17711
+ // lines.
17712
+ //
17713
+ // fix 1 (F6): the sentence that stood here said this branch catches "an
17714
+ // edit and its inverse inside one session". Re-derived, that is false
17715
+ // under per-gesture write-back — `lines` already holds the forward edit,
17716
+ // so the inverse produces a patch that DIFFERS from the current bytes and
17717
+ // pushes a real second op (MEASURED: paint then the overlay's own undo
17718
+ // gives `ops` 1 → 2, not `op === null`). The net-zero session is handled
17719
+ // further down instead, by unwinding the ops rather than by never having
17720
+ // pushed them.
17721
+ if (result.refused) {
17722
+ waveRefuseGesture(seam,
17723
+ '這個區塊的行範圍不合法(' + result.refused + '),所以沒有東西可以改');
17724
+ return;
17725
+ }
17726
+ seam.unwritten = false;
17727
+ waveUnwrittenEdit = false;
17728
+ setDirty();
17729
+ return;
17730
+ }
17731
+ lines = result.lines;
17732
+ // `blocks` is deliberately left alone until the session settles.
17733
+ // commitRangeEdit()'s shift anchor is the last block ENDING at or before
17734
+ // the edited range, and this range is the fence's BODY — strictly inside
17735
+ // the code block — so the anchor is the block BEFORE it and the shift would
17736
+ // move the wavedrom block's own startLine, which did not move. Leaving
17737
+ // `blocks` at its opening value keeps it merely STALE (every id present,
17738
+ // every span the one `.content` was rendered from) instead of wrong, and
17739
+ // nothing reads it while the modal owns the screen.
17740
+ //
17741
+ // fix 1 (F2): "the close settles it" is now true on BOTH close paths, not
17742
+ // only the one where the render succeeds — see settleWaveBlocks().
17743
+ seam.ops++;
17744
+ seam.endLine = seam.startLine + info.patch.text.split('\n').length - 1;
17745
+ seam.unwritten = false;
17746
+ waveUnwrittenEdit = false;
17747
+ // fix 1 (F5): a session that has come back to the bytes it started from
17748
+ // takes its own ops off the stack again, so the dirty dot goes out instead
17749
+ // of standing for the rest of the session over a byte-identical file.
17750
+ // MEASURED before this: paint, then the overlay's own undo — the document
17751
+ // was back to the original and the title stayed `● doc` permanently.
17752
+ if (bodyNow(seam) === seam.baseSource) unwindWaveOps(seam);
17753
+ setDirty();
17754
+ }
17755
+
17756
+ /** The block body as `lines` holds it right now. */
17757
+ function bodyNow(seam) {
17758
+ return lines.slice(seam.startLine - 1, seam.endLine).join('\n');
17759
+ }
17760
+
17761
+ /** One refusal, recorded and raised. The only producer of `waveUnwrittenEdit`. */
17762
+ function waveRefuseGesture(seam, reason) {
17763
+ // A refusal is not papered over and not rolled back. wave-ui has already
17764
+ // said so on the status line, which is where the user's eyes are, but a
17765
+ // status line is not a decision — this raises it as a banner naming the
17766
+ // lines it could not write and waits for the user to acknowledge it.
17767
+ seam.unwritten = true;
17768
+ waveUnwrittenEdit = true;
17769
+ setDirty();
17770
+ showWaveRefusal(seam, reason);
17771
+ }
17772
+
17773
+ /**
17774
+ * Take this session's own commits back off the document stack — bytes, stack
17775
+ * depth and redo branch — and say whether that was possible.
17776
+ *
17777
+ * What it does NOT give back is the SAVE MARKER. `push()` invalidates that
17778
+ * when history diverges past it, and a withdrawal cannot un-invalidate: the
17779
+ * ops that produced the saved bytes are gone from this stack whether or not
17780
+ * this session later changed its mind. MEASURED: edit, commit, Ctrl+S,
17781
+ * Ctrl+Z, open the editor, paint once, Escape, Ctrl+Y — the session is fully
17782
+ * withdrawn and the document is byte-identical to disk, and `●` still stands
17783
+ * until the next Ctrl+S. That is over-warning, which is the safe direction
17784
+ * and is deliberately not traded for a marker that could be wrong in the
17785
+ * other one.
17786
+ *
17787
+ * Returns false when it was NOT — see waveDiscardIsSafe() for the two
17788
+ * questions that decide it and for why neither is arithmetic over the dirty
17789
+ * depth any more. When it answers false a Ctrl+S landed in the middle (the
17790
+ * one gesture the modal deliberately does not swallow), or something other
17791
+ * than this session pushed, and `discardTop()` must not run: it would either
17792
+ * destroy the state disk was written from, or reverse somebody else's edit.
17793
+ *
17794
+ * The caller decides what to do about a false; this function never leaves the
17795
+ * stack half-unwound.
17796
+ */
17797
+ function unwindWaveOps(seam) {
17798
+ if (seam.ops === 0) return true;
17799
+ if (!waveDiscardIsSafe(stack, seam)) return false;
17800
+ for (let i = 0; i < seam.ops; i++) {
17801
+ if (stack.discardTop(lines) === null) break;
17802
+ }
17803
+ // fix 1 (F3): the session's first commit cleared the redo branch that
17804
+ // existed before it, and `discardTop()` has no way to put that back. A
17805
+ // session that is being withdrawn has to, or the comment below would be
17806
+ // claiming something that is measurably false.
17807
+ stack.setRedoTail(seam.baseRedoTail);
17808
+ // Restored BY VALUE rather than from discardTop()'s own returned arrays:
17809
+ // the two are the same answer, and only one of them can be off by a line.
17810
+ // discardTop() is still called for every op, because popping the stack is
17811
+ // the half `baseLines` cannot do.
17812
+ lines = seam.baseLines;
17813
+ blocks = seam.baseBlocks;
17814
+ seam.ops = 0;
17815
+ seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
17816
+ return true;
17817
+ }
17818
+
17819
+ /**
17820
+ * Make `blocks` agree with `lines` from the session's own arithmetic.
17821
+ *
17822
+ * Only ever needed because the session defers the block map to one render at
17823
+ * the end, and that render can fail. See shiftBlocksAfterBodyEdit()'s own
17824
+ * comment for what a stale `blocks` costs.
17825
+ */
17826
+ function settleWaveBlocks(seam) {
17827
+ blocks = shiftBlocksAfterBodyEdit(seam.baseBlocks, seam.blockId,
17828
+ lines.length - seam.baseLines.length);
17829
+ }
17830
+
17831
+ /**
17832
+ * Say, visibly, that a gesture could not be written — and name where.
17833
+ *
17834
+ * The status line inside the overlay already says it, and that is not
17835
+ * enough: a status line is read while you are looking for it, and this is
17836
+ * the one message in the feature that means "what you are looking at is not
17837
+ * what the file has". So it is raised as a banner — the same `.ed-conflict`
17838
+ * layer the disk-conflict notice uses, which is already painted ABOVE the
17839
+ * modal and already inside the dialog's focus trap (see `modalRoots()` in
17840
+ * wave-ui.js), so it is both visible and Tab-reachable without this having
17841
+ * to invent a second modal layer.
17842
+ *
17843
+ * Nothing is rewritten and nothing is rolled back: `store.toPatch()`'s
17844
+ * refusal travels to the user unchanged, which is what the whole codec layer
17845
+ * exists to make possible.
17846
+ *
17847
+ * ── One coordinate, in the frame the user is looking at (fix 1 / F4) ─────
17848
+ *
17849
+ * The first cut named two: the block's current document line range, and a
17850
+ * line number taken from `rewroteRange`'s offsets. The second was mapped
17851
+ * through the store's own `sourceText` — the body AS IT WAS WHEN THE EDITOR
17852
+ * OPENED, which is the only frame those offsets are exact in, since the store
17853
+ * is created once per session. That mapping was right and the REPORTING was
17854
+ * wrong: nothing in the sentence said the number belonged to a snapshot.
17855
+ * MEASURED after a rename plus two lane-adds — the banner said 「這個區塊自己的
17856
+ * 第 2 行」, which was the `clk` lane at open and is the just-added lane in the
17857
+ * file the user now has, with `clk` one line further down. A user who counts
17858
+ * two lines into the range the same sentence just gave them lands on the
17859
+ * wrong lane.
17860
+ *
17861
+ * It is dropped rather than labelled. A number that needs a paragraph of
17862
+ * frame-of-reference to be safe is not a coordinate, and the store's own
17863
+ * `reason` already names the path it is about — which is the thing that
17864
+ * survives a body whose line count has moved.
17865
+ */
17866
+ function showWaveRefusal(seam, reason) {
17867
+ const range = seam.startLine === seam.endLine
17868
+ ? ('第 ' + seam.startLine + ' 行')
17869
+ : ('第 ' + seam.startLine + '–' + seam.endLine + ' 行');
17870
+ // 知道了 is the confirmation the refusal waits for, and it is a SECOND
17871
+ // button rather than a relabelled ✕: the ✕ on this banner means the same
17872
+ // thing, but a dismiss glyph reads as "hide this" and the whole point of
17873
+ // this banner is that the user has registered what it says. Acknowledging
17874
+ // deliberately does NOT clear `waveUnwrittenEdit` — the work is still
17875
+ // missing from the file after the user has read about it, and the dirty
17876
+ // net is about the work, not about the notice.
17877
+ let el = null;
17878
+ el = showBanner(
17879
+ '這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
17880
+ '比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
17881
+ '(不含前後兩道圍欄)。原因:' + reason,
17882
+ '知道了',
17883
+ () => {
17884
+ if (el === null) return;
17885
+ el.remove();
17886
+ // Scoped to OUR element: a newer banner may have replaced this one on
17887
+ // screen between the refusal and the acknowledgement, and taking that
17888
+ // one down would hide something the user has not read.
17889
+ if (activeBanner === el) {
17890
+ activeBanner = null;
17891
+ activeBannerIsRefusal = false;
17892
+ activeBannerIsSwallow = false;
17893
+ activeBannerIsKeynavExit = false;
17894
+ }
17895
+ });
17896
+ }
17897
+
17898
+ /**
17899
+ * Settle a wave session that has just gone off screen.
17900
+ *
17901
+ * `reason` is wave-ui's, not inferred here: 'escape' means the user asked for
17902
+ * the session to be thrown away, anything else means they kept it.
17903
+ */
17904
+ /**
17905
+ * Give the keyboard back to the block the editor was opened over.
17906
+ *
17907
+ * v3.3.0's backlog had FOUR accessibility defects and this batch produced two
17908
+ * more, all of them the same sentence: a gesture ended and left
17909
+ * `document.activeElement` on `document.body`, where the user can see
17910
+ * nothing, Tab is deliberately swallowed, and the next Backspace means
17911
+ * whatever the last selection meant. The wave editor is the biggest surface
17912
+ * in the product to close, so it is the worst place to end there.
17913
+ *
17914
+ * The mark it leaves is a single-block SELECTION and not a bare `.focus()`,
17915
+ * because a bare focus is invisible: `.ed-block.ed-selected:focus` has
17916
+ * `outline: none` and the TINT is this editor's affordance for「鍵盤在這裡」
17917
+ * (see that rule's own comment in lib/md2doc.js). It also puts the user back
17918
+ * exactly where the keyboard entry above starts from, so Enter re-opens the
17919
+ * editor they just left.
17920
+ *
17921
+ * Scoped to `seam.reselect` — the sessions that took a keyboard which was
17922
+ * standing somewhere visible. A hover-and-click session is not made worse by
17923
+ * this and is not given a selection it never asked for.
17924
+ */
17925
+ function giveKeyboardBackToWaveBlock(seam) {
17926
+ if (seam === null || seam === undefined || seam.reselect !== true) return;
17927
+ // Resolved by id against the CURRENT map: the commit path has re-rendered
17928
+ // and re-numbered by now, and settleWaveBlocks() has already moved the
17929
+ // session's own line arithmetic into `blocks`. A block that is no longer
17930
+ // there (a render that failed and left the map short) simply gets nothing —
17931
+ // guessing at a neighbour is how a keyboard ends up selecting a block the
17932
+ // user never pointed at.
17933
+ const b = blocks.find((x) => x.id === seam.blockId);
17934
+ if (!b || !blockElById(seam.blockId)) return;
17935
+ setBlockSelection({ anchorLine: b.startLine, focusLine: b.startLine });
17936
+ }
17937
+
17938
+ async function finishWaveSession(seam, reason) {
17939
+ if (seam === null) return;
17940
+ if (reason === 'escape') {
17941
+ // Escape DISCARDS — the same word it means everywhere else in v3.3.0 —
17942
+ // and here that has to reach the document stack, because the session has
17943
+ // been committing all along.
17944
+ const painted = bodyNow(seam);
17945
+ const hadOps = seam.ops > 0;
17946
+ if (!unwindWaveOps(seam)) {
17947
+ // A Ctrl+S landed inside this session, so its commits cannot simply be
17948
+ // popped — see unwindWaveOps() for the measured failure that produces.
17949
+ // A revert COMMIT is the honest answer instead: the document goes back
17950
+ // to where the session found it, and the stack only ever grows, so
17951
+ // `dirtyDepth` stays strictly positive and can never walk back through
17952
+ // zero over a document that differs from disk. The cost is a `●` the
17953
+ // user clears with one Ctrl+S — which is TRUE, because disk is still
17954
+ // holding the drawing they just discarded.
17955
+ const result = commitRangeEdit({ lines, blocks, stack },
17956
+ seam.startLine, seam.endLine, seam.baseSource);
17957
+ if (result.op !== null) lines = result.lines;
17958
+ blocks = seam.baseBlocks;
17959
+ seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
17960
+ // fix 2 (R1): the redo branch, on THIS path too. Both Escape branches
17961
+ // end with `lines` byte-identical to `seam.baseLines` — only the seam
17962
+ // range is ever written during a session — so the pre-session redo ops
17963
+ // still address the document they were recorded against, and a user who
17964
+ // lands here is otherwise in exactly F3's position: a byte-identical
17965
+ // document with their redo silently eaten. AFTER the commit, because
17966
+ // `push()` is what cleared it.
17967
+ stack.setRedoTail(seam.baseRedoTail);
17968
+ }
17969
+ if (hadOps && painted !== seam.baseSource) {
17970
+ // …and the discard is recoverable by exactly one Ctrl+Z. See
17971
+ // discardedWaveEdit's own comment for which of discardedBurst's rules
17972
+ // this reuses and which of them cannot apply here.
17973
+ dropDiscardedBurst();
17974
+ dropDiscardedRawEdit();
17975
+ discardedWaveEdit = {
17976
+ blockId: seam.blockId,
17977
+ blockStartLine: seam.blockStartLine, blockEndLine: seam.blockEndLine,
17978
+ startLine: seam.startLine, endLine: seam.endLine,
17979
+ source: seam.baseSource, painted: painted,
17980
+ };
17981
+ }
17982
+ // Work the file could not hold goes with the session it belonged to.
17983
+ // The banner that said so is deliberately NOT dismissed here: it is the
17984
+ // only record the user has that something did not survive.
17985
+ waveUnwrittenEdit = false;
17986
+ setDirty();
17987
+ // No render. `.content` was never repainted while the overlay was up, so
17988
+ // it is still showing exactly the base document just restored — rendering
17989
+ // would be a round trip to say nothing.
17990
+ return;
17991
+ }
17992
+ if (seam.unwritten) {
17993
+ // Kept, with a hole in it. The user is told once more on the way out,
17994
+ // because the status line they were reading has just been removed from
17995
+ // the screen along with the rest of the dialog.
17996
+ showBanner(
17997
+ '這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
17998
+ '寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
17999
+ seam.endLine + ' 行)。',
18000
+ null, null);
18001
+ waveUnwrittenEdit = false;
18002
+ setDirty();
18003
+ }
18004
+ if (seam.ops === 0) return;
18005
+ // fix 1 (F2): `blocks` is settled from the session's own arithmetic BEFORE
18006
+ // the render, not instead of it. A successful render overwrites it with the
18007
+ // server's own map a moment later; a FAILED one — which this path
18008
+ // deliberately does not roll back — leaves this value standing instead of a
18009
+ // map addressing a document that no longer exists. It also closes the
18010
+ // window in which the overlay is reopened while the settling render is
18011
+ // still in flight, which reads `blocks` through waveBodyOf().
18012
+ settleWaveBlocks(seam);
18013
+ // The one render this session pays for. `.content` has been lagging
18014
+ // `lines` since the first gesture; this is where it catches up.
18015
+ // fix 1 (F7): the delta is the session's real line-count change, not 0.
18016
+ // Advisory today (patchmap() reads only its truthiness), wrong regardless.
18017
+ const editRange = { startLine: seam.startLine, endLine: seam.endLine,
18018
+ delta: lines.length - seam.baseLines.length };
18019
+ const ok = await safeRerenderAll(editRange);
18020
+ if (!ok) {
18021
+ // Deliberately NOT rollbackFailedRender(). Every other commit in this
18022
+ // file rolls back a failed render because the edit it is undoing is one
18023
+ // keystroke; this one can be ten minutes of drawing, and rerenderAll()'s
18024
+ // failure is a round trip to /api/render, not a refusal of the edit. The
18025
+ // work stays in `lines` — dirty, saveable with Ctrl+S, and recoverable by
18026
+ // an ordinary Ctrl+Z — and the banner is corrected, because
18027
+ // rerenderAll()'s own 'Your edit was not applied' would be false here.
18028
+ //
18029
+ // What makes that safe is settleWaveBlocks() above: keeping the work is
18030
+ // only defensible while `blocks` still addresses the document that holds
18031
+ // it. Losing a session is bad; silently corrupting the document the user
18032
+ // keeps editing afterwards is worse, and before fix 1 this path risked
18033
+ // both at once.
18034
+ showBanner(
18035
+ '畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
18036
+ 'Ctrl+Z 可以撤銷)。', null, null);
18037
+ }
18038
+ }
18039
+
18040
+ /**
18041
+ * Ctrl+Z straight after an Escape out of the wave editor: put the discarded
18042
+ * drawing back and re-open the editor over it.
18043
+ *
18044
+ * The restore is an ordinary commit, not a special case — so the reopened
18045
+ * session opens over a document that already holds the drawing, one more
18046
+ * Ctrl+Z steps back over the restore itself, and Ctrl+S saves it. That is
18047
+ * the difference from restoreDiscardedRawEdit(), whose stashed value lives
18048
+ * in a textarea until a later blur commits it: there is no "uncommitted
18049
+ * surface" shape available here, because the wave editor's surface IS the
18050
+ * document.
18051
+ *
18052
+ * Staleness is asked at the `lines`/`blocks` level, the same level
18053
+ * restoreDiscardedRawEdit() asks at and for the same reason — the overlay
18054
+ * was removed from the DOM by `close()`, so there is no node whose presence
18055
+ * could answer it.
18056
+ */
18057
+ async function restoreDiscardedWaveEdit() {
18058
+ const d = discardedWaveEdit;
18059
+ discardedWaveEdit = null;
18060
+ if (!d) return false;
18061
+ const b = blocks.find((x) => x.id === d.blockId);
18062
+ if (!b || b.startLine !== d.blockStartLine || b.endLine !== d.blockEndLine) return false;
18063
+ if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
18064
+ const prevLines = lines;
18065
+ const result = commitRangeEdit({ lines, blocks, stack },
18066
+ d.startLine, d.endLine, d.painted);
18067
+ if (result.op === null) return false;
18068
+ lines = result.lines;
18069
+ // `blocks` deliberately not reassigned — the same shape every other
18070
+ // commit-then-render site in this file has: the render that follows is what
18071
+ // replaces it, and rollbackFailedRender() below restores `lines` only, so
18072
+ // an assignment here would survive a failed render as the one value nothing
18073
+ // puts back.
18074
+ setDirty();
18075
+ const ok = await safeRerenderAll({
18076
+ startLine: d.startLine,
18077
+ endLine: d.startLine + d.painted.split('\n').length - 1,
18078
+ delta: result.op.after.length - result.op.before.length,
18079
+ });
18080
+ if (!ok) {
18081
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
18082
+ setDirty();
18083
+ return false;
18084
+ }
18085
+ // Re-open over the block the render just rebuilt. `openWaveEditor()` wants
18086
+ // something INSIDE the block and resolves the block with `closest()`, and
18087
+ // `closest()` matches the element itself — so the block element is a valid
18088
+ // argument and does not depend on WaveDrom having finished painting a
18089
+ // `.wavedrom-diagram` into it yet.
18090
+ const blockEl = blockElById(d.blockId);
18091
+ if (!blockEl) return true; // the drawing is back in the document either way
18092
+ await openWaveEditor(blockEl);
18093
+ return true;
18094
+ }
18095
+
18096
+ // Test-only seam, the same shape and for the same reason as
18097
+ // `__edTestGetSelection` above: the two invariants this feature has that a
18098
+ // scenario cannot otherwise see — whether the overlay is open, and whether
18099
+ // anything ever reached the seam after it closed. Nothing in the product
18100
+ // calls it.
18101
+ window.__edTestWaveState = function () {
18102
+ return {
18103
+ open: waveEditor !== null,
18104
+ stray: waveStrayGestures,
18105
+ // Task 7's three: whether the document currently believes it has
18106
+ // anything unsaved (the thing the Reload button's beforeunload guard
18107
+ // asks), whether this session has work the file could not hold, and
18108
+ // whether a discarded session is sitting there waiting for a Ctrl+Z.
18109
+ dirty: documentIsDirty(),
18110
+ unwritten: waveUnwrittenEdit,
18111
+ stashed: discardedWaveEdit !== null,
18112
+ seam: waveSeam === null ? null : {
18113
+ blockId: waveSeam.blockId,
18114
+ startLine: waveSeam.startLine,
18115
+ endLine: waveSeam.endLine,
18116
+ gestured: waveSeam.lastGesture !== null,
18117
+ ops: waveSeam.ops,
18118
+ },
18119
+ };
18120
+ };
18121
+
18122
+ // fix 1 (F2): whether the block map still addresses the document. A failed
18123
+ // close render used to leave the two disagreeing by the session's line delta,
18124
+ // and the only way a scenario could see it was to make an ordinary edit
18125
+ // afterwards and read the corrupted file — a real assertion, but one that
18126
+ // cannot say WHICH of the two moved. Nothing in the product calls this.
18127
+ window.__edTestBlockSpan = function (id) {
18128
+ const b = blocks.find((x) => x.id === id);
18129
+ return b === undefined ? null : {
18130
+ startLine: b.startLine, endLine: b.endLine, docLines: lines.length,
18131
+ text: lines.slice(b.startLine - 1, b.endLine).join('\n'),
18132
+ };
18133
+ };
18134
+
18135
+ waveEditBtn.addEventListener('click', () => {
18136
+ if (waveDiagramEl === null) return;
18137
+ // The only way this rejects is a programming error — the `.content` mount
18138
+ // guard, which throws by design, or a store that threw where it promised
18139
+ // not to. Reported rather than swallowed, and NOT rethrown: an unhandled
18140
+ // rejection out of a click handler is a page-level error, and the state
18141
+ // this needs to keep consistent (`waveEditor` / `waveSeam`) is already
18142
+ // unwound above.
18143
+ openWaveEditor(waveDiagramEl).catch((err) => {
18144
+ console.error('md2doc: the wave editor could not open', err);
18145
+ });
18146
+ });
18147
+
14952
18148
  // v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
14953
18149
  // selToolbar, …) is initialised and the bar's first deriveState() sees the
14954
18150
  // armed document rather than an empty one.