@helping-ai-workflow/md2doc 3.12.1 → 3.13.1

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.
@@ -650,6 +650,12 @@
650
650
  // v3.4.0 batch3 Task 7 fix 2 (MUST-FIX 1) — may a wave session's own commits
651
651
  // simply be popped off the document stack?
652
652
  //
653
+ // Since wave redesign Task 11 its one caller is the net-zero unwind
654
+ // (unwindWaveOps(), when a session's drawing is back at its opening bytes):
655
+ // closing the editor never pops anything any more. The MEASURED sequences
656
+ // below that end in an Escape were taken on the retired Escape discard,
657
+ // which asked these same two questions.
658
+ //
653
659
  // Two questions, asked separately, each answered by the stack itself rather
654
660
  // than by arithmetic over a quantity that can be wrong:
655
661
  //
@@ -707,8 +713,36 @@
707
713
  return stack.savedDepth === null || stack.savedDepth <= seam.baseDepth;
708
714
  }
709
715
 
716
+ // Wave redesign Task 11 — does a whole-session undo (or its Ctrl+Y) offer
717
+ // still describe the document? `now` is `{ lines, depth, burstOpen }`. The
718
+ // offer holds only while `lines` is the very array it was made over (every
719
+ // edit, undo, redo and reload replaces it — equal bytes in a new array still
720
+ // mean something happened), the stack is at the same depth, and no burst
721
+ // holds typing `lines` has not seen yet. Pure, so test/editor-client.test.js
722
+ // drives it; waveUndoOfferValid() / waveRedoOfferValid() feed it the live
723
+ // state.
724
+ function waveOfferHolds(offer, now) {
725
+ return offer !== null && offer !== undefined && now.lines === offer.lines &&
726
+ now.depth === offer.depth && now.burstOpen !== true;
727
+ }
728
+
729
+ // Task 11 fix round 1 (Ruling R24) — may Ctrl+Y re-apply a whole-session undo
730
+ // as one step? `ro` is `{ baseDepth, ops }`, `ops` the entries the undo
731
+ // popped, in pop order; `now` is `{ depth, tail, burstOpen }`, `tail` the
732
+ // stack's redo branch (redoTail(), top last). It holds while the stack is at
733
+ // the session's opening depth with exactly those op OBJECTS on top of the
734
+ // redo branch — what an ordinary undo and redo of something else leaves
735
+ // standing, and what any new edit (which clears the branch) does not.
736
+ function waveRedoHolds(ro, now) {
737
+ if (ro === null || ro === undefined || now.burstOpen === true || now.depth !== ro.baseDepth) return false;
738
+ const n = ro.ops.length;
739
+ if (n === 0 || now.tail.length < n) return false;
740
+ const top = now.tail.slice(now.tail.length - n);
741
+ return top.every((op, i) => op === ro.ops[i]);
742
+ }
743
+
710
744
  if (typeof module === 'object' && module.exports) {
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 };
745
+ module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
712
746
  return; // node: pure core only
713
747
  }
714
748
 
@@ -888,44 +922,51 @@
888
922
  discardedRawEdit = null;
889
923
  }
890
924
 
891
- // v3.4.0 batch3 Task 7 — the wave-editor member of the same family as the
892
- // two stashes above. Escape out of the wave overlay still DISCARDS, and this
893
- // is what makes the discard recoverable by exactly one document-level
894
- // Ctrl+Z, the same one-shot contract `discardedBurst` spells out.
895
- //
896
- // Which of discardedBurst's rules are reused here, and which cannot be:
897
- //
898
- // REUSED — the one-shot window (the only Ctrl+Z it answers is the one
899
- // right after the Escape that created it), the drop-on-any-render rule
900
- // (rerenderAll() clears all three at its top), mutual exclusivity with the
901
- // other two, and a staleness guard that refuses rather than applying a
902
- // restore over a document that moved underneath it.
903
- //
904
- // NOT APPLICABLE — parking a live `history` object keyed on the burst's
905
- // own DOM node. discardedBurst can do that because the burst's
906
- // contenteditable survives its own Escape; the wave overlay is mounted on
907
- // `document.body` and `close()` REMOVES it (and every document listener it
908
- // installed) before onClose ever runs, so there is no node and no live
909
- // store left to park. Nor is `editEl`/`burstBaselineHtml()` meaningful:
910
- // nothing the wave editor produces was ever HTML in `.content`.
911
- //
912
- // NOT APPLICABLE — "the burst never committed, so `lines` never saw it".
913
- // The wave editor writes back PER GESTURE, so by the time Escape is
914
- // pressed `lines` has seen every one of them and the document stack is
915
- // carrying them. That inverts what Escape has to do: it is the one stash
916
- // in this file whose creation REMOVES ops from the stack (see
917
- // finishWaveSession()) rather than never having put any there.
918
- //
919
- // So this parks a plain VALUE pair — the block body as it stood before the
920
- // session (`source`, which is what `lines` holds again once the Escape has
921
- // run) and as it stood at the moment of the Escape (`painted`) — plus enough
922
- // addressing to prove nothing else moved. Ctrl+Z's job is to put `painted`
923
- // back through an ordinary commit and RE-OPEN the editor over it.
924
- let discardedWaveEdit = null; // { blockId, blockStartLine, blockEndLine, startLine, endLine, source, painted } | null
925
-
926
- function dropDiscardedWaveEdit() {
927
- discardedWaveEdit = null;
928
- }
925
+ // Wave redesign Task 11 (spec section 4.8) — the first Ctrl+Z after the
926
+ // wave editor closes takes that whole session back as ONE user step.
927
+ //
928
+ // Closing always keeps (✕, 完成 and Esc are all only "close"): every gesture
929
+ // was written into `lines` as it was drawn, so there is nothing to throw
930
+ // away. What the user may want instead is to take the whole drawing back,
931
+ // and one Ctrl+Z per gesture is the wrong size for that. So the close leaves
932
+ // an offer, made by finishWaveSession() once its render is done:
933
+ //
934
+ // { blockId, startLine, endLine, baseSource, baseDepth, lines, depth, count }
935
+ //
936
+ // blockId the block, found again by id in the CURRENT `blocks` when the
937
+ // offer is used — never by the line numbers it had at the close
938
+ // startLine, endLine the body range at the close (a record, not used to
939
+ // address anything)
940
+ // baseSource the block body when the editor OPENED: the seam's own copy,
941
+ // never the store's source, which rebases on every write-back
942
+ // (Rulings R16/R17)
943
+ // lines the `lines` ARRAY at the close, by reference, and
944
+ // depth `stack.depth` at the close. Together they are the whole
945
+ // validity contract (waveUndoOfferValid()): every edit, undo,
946
+ // redo and reload replaces `lines`, so an offer whose array is
947
+ // no longer the live one belongs to a document that has moved
948
+ // on, even when the bytes happen to be equal again
949
+ // baseDepth the stack depth when the editor opened (seam.baseDepth); the
950
+ // offer is only made when the close finds the stack exactly
951
+ // `baseDepth + count` deep, i.e. the session's own entries on top
952
+ // count how many gestures the session wrote (seam.ops): the toast's N,
953
+ // and how many entries the revert pops (Ruling R24)
954
+ //
955
+ // It is spent by the Ctrl+Z that asks it, whether or not it still held. The
956
+ // step itself (undoWaveSession(), Ruling R24) pops the session's `count`
957
+ // entries off the stack and renders once. It is not the old Escape discard's
958
+ // unwind: that one ran before any render, never rendered, and threw the
959
+ // entries away (discardTop); this keeps them on the redo branch.
960
+ //
961
+ // `waveRedoOffer` is the other half (spec: 可再被 Ctrl+Y 取消): made when
962
+ // the revert lands, `{ baseDepth, ops, offer }` (`ops` the popped entries),
963
+ // and Ctrl+Y while the stack still stands there (waveRedoOfferValid())
964
+ // re-applies them as one step and re-makes the undo offer.
965
+ //
966
+ // Declared up here, beside the stashes, for the same temporal-dead-zone
967
+ // reason as `waveUnwrittenEdit` below.
968
+ let waveUndoOffer = null;
969
+ let waveRedoOffer = null;
929
970
 
930
971
  // v3.4.0 batch3 Task 7 — a wave gesture whose patch was REFUSED, i.e. work
931
972
  // the user can see in the overlay that `lines` does not hold and cannot be
@@ -942,6 +983,14 @@
942
983
  // definition nothing reached `lines`.
943
984
  let waveUnwrittenEdit = false;
944
985
 
986
+ // wave redesign Task 6b: the open wave editor's `setSaveStatus`, or null.
987
+ // The editor's title bar shows the FILE's save state (spec section 4.8),
988
+ // so setDirty() pushes every change to it. Kept up here rather than read
989
+ // off `waveEditor` for the same reason as `waveUnwrittenEdit` above:
990
+ // setDirty() is reachable before the wave wiring's own `let`s far below
991
+ // have left their temporal dead zone.
992
+ let waveSaveStatusSink = null;
993
+
945
994
  // §10-gap fix (review): the block insertBlockBelow() most recently
946
995
  // inserted, tracked from the moment its edit surface is first focused
947
996
  // until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
@@ -1061,7 +1110,9 @@
1061
1110
  let switching = null;
1062
1111
  function switchAwayFrom() {
1063
1112
  if (switching) return switching;
1064
- switching = resolveOpenSession().finally(() => { switching = null; });
1113
+ // v3.13.1 T1: a held-key replay that waited on this resolution resumes
1114
+ // once it is over (scheduleFlightFlush() is a no-op with nothing held).
1115
+ switching = resolveOpenSession().finally(() => { switching = null; scheduleFlightFlush(); });
1065
1116
  return switching;
1066
1117
  }
1067
1118
 
@@ -1147,6 +1198,7 @@
1147
1198
  function setDirty() {
1148
1199
  document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
1149
1200
  paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
1201
+ if (waveSaveStatusSink !== null) waveSaveStatusSink(documentIsDirty());
1150
1202
  }
1151
1203
 
1152
1204
  // setDirty() is push-driven: something has to call it. MEASURED by keeping
@@ -1323,6 +1375,33 @@
1323
1375
  }
1324
1376
  function showError(message, actionLabel, onAction) { return showBanner(message, actionLabel, onAction, 'error'); }
1325
1377
  function showNotice(message) { return showBanner(message, null, null, 'notice'); }
1378
+ // Wave redesign Task 6b fix round 1 (spec E2): the wave editor's notices
1379
+ // must never take the place of a red card that is still up — a write-back
1380
+ // refusal, a failed save, the disk-conflict card with 重新載入. With one
1381
+ // banner on the page, showBanner() would replace it, and that card is the
1382
+ // only record of the problem. The notice is dropped instead (the editor
1383
+ // still keeps it in its own data-wave-status). Scoped to the wave editor's
1384
+ // messages rather than changed inside showNotice() for every caller: the
1385
+ // global change could only be verified by the long editor-journey suite,
1386
+ // which this phase of the redesign does not run.
1387
+ //
1388
+ // Task 11: while the editor is open the toast sits just above its hint bar
1389
+ // (spec section 4.8: 編輯器內底部置中) rather than at the viewport's
1390
+ // `bottom: 28px`. MEASURED at 1366x768 before this: the toast spanned
1391
+ // y 696-740 and the hint bar 697-729, so the toast covered the middle of
1392
+ // the bar the user reads for the next step. Measured off the bar itself, so
1393
+ // any panel size or bar height lands it right.
1394
+ function showWaveNotice(message, actionLabel, onAction) {
1395
+ if (activeBanner !== null && document.contains(activeBanner) &&
1396
+ activeBanner.getAttribute('data-level') === 'error') return null;
1397
+ const el = showBanner(message, actionLabel, onAction, 'notice');
1398
+ const hintBar = waveEditorIsModal() ? document.querySelector('.ed-wave-overlay .ed-wave-hint') : null;
1399
+ if (el !== null && hintBar !== null) {
1400
+ const top = hintBar.getBoundingClientRect().top;
1401
+ el.style.bottom = Math.max(8, Math.round(window.innerHeight - top + 8)) + 'px';
1402
+ }
1403
+ return el;
1404
+ }
1326
1405
 
1327
1406
  // Task 9 review I3: MEASURED AT THE TIME — `.ed-conflict` was
1328
1407
  // `position:fixed; top:0; z-index:999` against `.ed-toolbar`'s
@@ -1554,9 +1633,6 @@
1554
1633
  // twin — this render is about to replace `lines`' rendering of whatever
1555
1634
  // discardedRawEdit's stashed `source` names.
1556
1635
  dropDiscardedRawEdit();
1557
- // v3.4.0 batch3 Task 7: and the wave twin — its staleness guard compares
1558
- // `lines` at a span, which this render is about to repaint from.
1559
- dropDiscardedWaveEdit();
1560
1636
  const scrollY = window.scrollY;
1561
1637
  // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
1562
1638
  // for the rebuilt selection. Consumed HERE, before the first failure exit,
@@ -1611,6 +1687,11 @@
1611
1687
  // through explicitly. Passed positionally here and through
1612
1688
  // applyRenderResult() rather than folded into `j` (which is the
1613
1689
  // server's response payload, not render-context the CALLER derived).
1690
+ // v3.13.1 R11: the selection right before the DOM is replaced — a click,
1691
+ // drag or double-click into another block during this request has
1692
+ // already put it there. The focusin re-resolve and the flight replay
1693
+ // restore it, anchor and focus both.
1694
+ lastRenderSel = selectionSnapshotIn(activeFlightSurface());
1614
1695
  return applyRenderResult(j, editRange, scrollY, declaredRange);
1615
1696
  }
1616
1697
 
@@ -2590,14 +2671,457 @@
2590
2671
  // refusal, and commitListStructure()'s no-range exit) pass nothing, which
2591
2672
  // falls back to a full re-render by contract — patch can only compare
2592
2673
  // renderer output, never DOM the user has already touched.
2674
+ //
2675
+ // v3.13.1 T1: every render is also a "commit in flight" for the input guard
2676
+ // below — see `commitFlight`. A render that lands re-points every held
2677
+ // entry's tag at the surface that now holds the same source.
2593
2678
  async function safeRerenderAll(editRange) {
2679
+ const blocksBefore = blocks;
2680
+ const lineMap = pendingFlightLineMap;
2681
+ pendingFlightLineMap = null;
2682
+ beginCommitFlight();
2683
+ let ok = false;
2594
2684
  try {
2595
- return await rerenderAll(editRange);
2685
+ ok = await rerenderAll(editRange);
2686
+ return ok;
2596
2687
  } catch (e) {
2597
2688
  showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
2598
2689
  ')。文件維持修改前的樣子。', null, null);
2599
2690
  return false;
2691
+ } finally {
2692
+ if (ok) reanchorFlightBuffer(blocksBefore, editRange, lineMap);
2693
+ // A failed render: nothing places focus afterwards (commitListStructure()
2694
+ // skips its focusBlockAtLine(), and a failed blur-commit refocuses the
2695
+ // block it could not commit), so the opening run replays where it was
2696
+ // typed. Measured before round 3: edit A, click B@5, `ZZ`, render 500 —
2697
+ // `ZZ` went to the start of A.
2698
+ else pinFirstFlightRun(null);
2699
+ endCommitFlight();
2700
+ }
2701
+ }
2702
+
2703
+ // ── v3.13.1 T1: the commit-in-flight input guard ──────────────────────
2704
+ // A commit awaits /api/render and then rebuilds the surfaces it covers from
2705
+ // `lines`. Anything typed into a surface while that request is out lands in
2706
+ // DOM the render is about to throw away. Measured before this (audit
2707
+ // 2026-10-10): Enter then an immediate `type('new')` in a tight list lost
2708
+ // every character in Chromium and wrote an extra empty `-` item; with the
2709
+ // render held 800 ms, 100% loss in both engines.
2710
+ //
2711
+ // While a flight is active (flightActive(): a commit in flight, or held
2712
+ // entries still waiting to replay), what is typed into an armed surface is held in
2713
+ // `flightBuffer` instead of reaching the DOM — text, paste, and the keys
2714
+ // that edit or move: Enter, Tab, Backspace/Delete (and their word/line
2715
+ // chords), arrows, Home/End. Escape drops everything held (R12: Escape
2716
+ // means "throw away what I have not committed", in flight or not). Once
2717
+ // the render has landed and its caller has placed focus, the entries are
2718
+ // replayed in order, each through the path the live input would have taken
2719
+ // (ruling R13): text through the browser's own insertText (which replaces a
2720
+ // selection and rebalances whitespace; insertTextAtCaret() is the fallback),
2721
+ // paste through routePaste(), keys as a keydown through the
2722
+ // document handler — so ↑/↓ can still cross blocks and a table cell still
2723
+ // owns its keys — and, when no handler claims the key, its native default
2724
+ // (execCommand deletion, Selection.modify movement).
2725
+ //
2726
+ // WHERE an entry replays (fix rounds 1-3):
2727
+ // * A "continuation" entry (pos === null) replays at the selection the
2728
+ // replay has reached — wherever focus is once the caller placed it, or
2729
+ // wherever an earlier replayed key moved it (a held ArrowDown that walks
2730
+ // to the next block takes the keys typed after it there). A held key
2731
+ // never moves the DOM selection; a pointer press does, so the press
2732
+ // first PINS the run of continuation entries that opened the flight to
2733
+ // the selection it is about to move away from (pinFirstFlightRun(); a
2734
+ // failed render pins it too, since then no caller places focus). Every
2735
+ // other run already starts at a positioned entry. Measured before round
2736
+ // 3: click B@5, `xy`, click Omega, `Z` replayed `xy` at B's start.
2737
+ // * A "positioned" entry is the first one held after a pointer press into
2738
+ // a surface during the flight: it carries a tag (surface, block id,
2739
+ // table cell index) and the full selection (anchor and focus offsets),
2740
+ // restored before it replays. Offsets are shifted past the held text
2741
+ // that will be inserted before them in the same surface by the time it
2742
+ // replays (heldShiftBefore()). The tag is re-pointed when a render
2743
+ // replaces its surface: by the committed run's own line map for an item
2744
+ // of a list run the commit rewrote (an item the split pushed down a
2745
+ // line, the provisional item), otherwise by its block's start line,
2746
+ // shifted by the commit's line delta when it lay below the edited range.
2747
+ // Measured before round 2: Enter, `ab`, click `charlie`, `Z` saved
2748
+ // `- abZ` with charlie untouched.
2749
+ // * A pointer press during the flight with nothing typed after it becomes
2750
+ // a { kind: 'focus' } entry, so the click is not lost to the commit's
2751
+ // own focusBlockAtLine().
2752
+ //
2753
+ // A failed render keeps the buffer too: rerenderAll() leaves `.content`
2754
+ // untouched on failure, every surface is still connected, and the replay
2755
+ // lands where it was typed.
2756
+ let commitFlight = 0;
2757
+ const flightBuffer = [];
2758
+ // A pointer press into a surface during a flight: the next held entry is
2759
+ // positioned (see above).
2760
+ let flightSelMoved = false;
2761
+ // Set while an entry replays: the replay's own keydown / insertText must
2762
+ // not be held again.
2763
+ let flightReplaying = false;
2764
+ function flightActive() {
2765
+ return !flightReplaying && (commitFlight > 0 || flightBuffer.length > 0);
2766
+ }
2767
+ // The selection right before the latest render replaced the DOM (R11).
2768
+ let lastRenderSel = null;
2769
+ // Where continuation entries go when the render took focus away and no
2770
+ // caller put it anywhere (e.g. a Ctrl+S commit of the focused block).
2771
+ let flightFallback = null;
2772
+ // commitListStructure() -> safeRerenderAll(): the run's line map.
2773
+ let pendingFlightLineMap = null;
2774
+ const FLIGHT_DROPPED_MESSAGE = '重新整理期間打的字沒有地方放,已略過';
2775
+
2776
+ function beginCommitFlight() {
2777
+ commitFlight++;
2778
+ }
2779
+
2780
+ // Not a synchronous decrement. The caller's continuation after
2781
+ // `await safeRerenderAll(...)` — focusBlockAtLine(), an arrow walk's
2782
+ // landing — is a microtask, so it runs before this timer and focus is
2783
+ // placed before the replay. Keys that arrive between the render landing
2784
+ // and the timer are still held, behind the earlier ones.
2785
+ function endCommitFlight() {
2786
+ setTimeout(() => {
2787
+ commitFlight--;
2788
+ if (!commitFlight) flushFlightBuffer();
2789
+ }, 0);
2790
+ }
2791
+
2792
+ // A replay that had to wait for a focus change resumes from here (the
2793
+ // focusin handlers call it once their burst is open).
2794
+ function scheduleFlightFlush() {
2795
+ if (flightBuffer.length && !commitFlight) setTimeout(flushFlightBuffer, 0);
2796
+ }
2797
+
2798
+ function flightSurfaceOf(node) {
2799
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
2800
+ return el && el.closest ? el.closest('.ed-wys-armed, .ed-wys-cell') : null;
2801
+ }
2802
+ function activeFlightSurface() {
2803
+ return flightSurfaceOf(document.activeElement);
2804
+ }
2805
+
2806
+ // Selection points as character offsets inside a surface, counted over
2807
+ // text nodes only — the same count pointInSurface() walks back.
2808
+ function offsetInSurface(surface, node, offset) {
2809
+ const r = document.createRange();
2810
+ r.selectNodeContents(surface);
2811
+ r.setEnd(node, offset);
2812
+ return r.toString().length;
2813
+ }
2814
+ function pointInSurface(surface, offset) {
2815
+ let left = offset;
2816
+ let lastText = null;
2817
+ const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
2818
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
2819
+ if (left <= t.data.length) return [t, left];
2820
+ left -= t.data.length;
2821
+ lastText = t;
2822
+ }
2823
+ return lastText ? [lastText, lastText.data.length] : [surface, 0];
2824
+ }
2825
+ // { surface, anchor, focus } for the selection in `surface`; offsets are
2826
+ // null when the selection is not inside it.
2827
+ function selectionSnapshotIn(surface) {
2828
+ if (!surface) return null;
2829
+ const sel = window.getSelection();
2830
+ if (!sel.rangeCount || !surface.contains(sel.anchorNode) || !surface.contains(sel.focusNode)) {
2831
+ return { surface, anchor: null, focus: null };
2832
+ }
2833
+ return { surface,
2834
+ anchor: offsetInSurface(surface, sel.anchorNode, sel.anchorOffset),
2835
+ focus: offsetInSurface(surface, sel.focusNode, sel.focusOffset) };
2836
+ }
2837
+ function restoreSelection(surface, anchor, focus) {
2838
+ if (anchor === null || anchor === undefined) return;
2839
+ const a = pointInSurface(surface, anchor);
2840
+ const f = pointInSurface(surface, focus === null || focus === undefined ? anchor : focus);
2841
+ window.getSelection().setBaseAndExtent(a[0], a[1], f[0], f[1]);
2842
+ }
2843
+
2844
+ function flightTagOf(snap) {
2845
+ if (!snap || !snap.surface) return null;
2846
+ const blockEl = snap.surface.closest('.ed-block');
2847
+ const isCell = snap.surface.classList.contains('ed-wys-cell');
2848
+ return {
2849
+ surface: snap.surface,
2850
+ blockId: blockEl ? blockEl.getAttribute('data-block-id') : null,
2851
+ cellIndex: isCell && blockEl
2852
+ ? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
2853
+ anchor: snap.anchor, focus: snap.focus,
2854
+ };
2855
+ }
2856
+
2857
+ // The committed run's lines after the commit: the serializer's own
2858
+ // line -> block map (`lineMeta`, one entry per emitted line), keyed by the
2859
+ // block ids the run had before it. The provisional item has no id.
2860
+ function flightLineMapOf(lineMeta, startLine) {
2861
+ const map = { ids: {}, provisional: null };
2862
+ (lineMeta || []).forEach((m, k) => {
2863
+ if (m.blank) return;
2864
+ if (m.blockId === null || m.blockId === undefined) {
2865
+ if (map.provisional === null) map.provisional = startLine + k;
2866
+ } else if (!(m.blockId in map.ids)) {
2867
+ map.ids[m.blockId] = startLine + k;
2868
+ }
2869
+ });
2870
+ return map;
2871
+ }
2872
+
2873
+ // How far held text will have pushed `offset` in `surface` by the time an
2874
+ // entry positioned there replays: the text of every run pinned (or, for
2875
+ // the opening run, typed) in the same surface at or before that offset.
2876
+ function heldShiftBefore(surface, offset) {
2877
+ let pin = null;
2878
+ let shift = 0;
2879
+ flightBuffer.forEach((en, i) => {
2880
+ if (en.pos || i === 0) pin = en.pos || en.hint;
2881
+ if (en.kind !== 'text' || !pin || pin.surface !== surface || pin.anchor === null) return;
2882
+ if (Math.min(pin.anchor, pin.focus === null ? pin.anchor : pin.focus) <= offset) shift += en.data.length;
2883
+ });
2884
+ return shift;
2885
+ }
2886
+ function shiftedTag(tag) {
2887
+ if (!tag || tag.anchor === null) return tag;
2888
+ tag.anchor += heldShiftBefore(tag.surface, tag.anchor);
2889
+ if (tag.focus !== null) tag.focus += heldShiftBefore(tag.surface, tag.focus);
2890
+ return tag;
2891
+ }
2892
+
2893
+ // Pins the run of continuation entries that opened this flight (if it is
2894
+ // still unpinned) to `snap`, or to its own first entry's hint — where it
2895
+ // was typed — when there is no usable selection.
2896
+ function pinFirstFlightRun(snap) {
2897
+ const first = flightBuffer[0];
2898
+ if (!first || first.pos) return;
2899
+ const tag = snap && snap.anchor !== null ? flightTagOf(snap) : null;
2900
+ first.pos = tag || (first.hint ? Object.assign({}, first.hint) : null);
2901
+ }
2902
+
2903
+ function holdFlightEntry(surface, entry) {
2904
+ entry.pos = null;
2905
+ entry.hint = flightTagOf(selectionSnapshotIn(surface));
2906
+ if (flightSelMoved) {
2907
+ flightSelMoved = false;
2908
+ entry.pos = shiftedTag(flightTagOf(selectionSnapshotIn(surface)));
2909
+ }
2910
+ const last = flightBuffer[flightBuffer.length - 1];
2911
+ if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
2912
+ last.data += entry.data;
2913
+ return;
2914
+ }
2915
+ flightBuffer.push(entry);
2916
+ }
2917
+
2918
+ // After a render lands: re-point every tag whose surface it replaced.
2919
+ function reanchorFlightBuffer(before, editRange, lineMap) {
2920
+ if (flightSelMoved && lastRenderSel) {
2921
+ flightSelMoved = false;
2922
+ flightBuffer.push({ kind: 'focus', pos: shiftedTag(flightTagOf(lastRenderSel)) });
2923
+ }
2924
+ flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
2925
+ ? flightTagOf(lastRenderSel) : null;
2926
+ const remap = (pos) => {
2927
+ if (!pos) return;
2928
+ if (pos.surface && pos.surface.isConnected) {
2929
+ const own = pos.surface.closest('.ed-block');
2930
+ pos.blockId = own ? own.getAttribute('data-block-id') : pos.blockId;
2931
+ return;
2932
+ }
2933
+ let line = null;
2934
+ if (lineMap) {
2935
+ if (pos.blockId === null) line = lineMap.provisional;
2936
+ else if (pos.blockId in lineMap.ids) line = lineMap.ids[pos.blockId];
2937
+ }
2938
+ if (line === null && pos.blockId !== null) {
2939
+ const rec = (before || []).find((b) => b.id === Number(pos.blockId));
2940
+ if (rec) {
2941
+ line = rec.startLine;
2942
+ if (editRange && line > editRange.endLine) line += editRange.delta;
2943
+ }
2944
+ }
2945
+ pos.surface = null;
2946
+ if (line === null) return;
2947
+ const nb = blocks.find((b) => b.startLine === line);
2948
+ const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
2949
+ if (!nEl) return;
2950
+ let surf = pos.cellIndex !== null && pos.cellIndex !== undefined
2951
+ ? nEl.querySelectorAll('.ed-wys-cell')[pos.cellIndex] || null
2952
+ : blockContentEl(nEl);
2953
+ if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
2954
+ pos.surface = surf;
2955
+ pos.blockId = String(nb.id);
2956
+ };
2957
+ flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
2958
+ remap(flightFallback);
2959
+ }
2960
+
2961
+ // A deletion chord's input type, and a caret chord's movement, per
2962
+ // platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
2963
+ const FLIGHT_IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
2964
+ function deletionForKey(e) {
2965
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
2966
+ const line = FLIGHT_IS_MAC && e.metaKey && !e.altKey && !e.ctrlKey;
2967
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
2968
+ if (e.key === 'Backspace') {
2969
+ return plain ? 'deleteContentBackward' : word ? 'deleteWordBackward' : line ? 'deleteSoftLineBackward' : null;
2600
2970
  }
2971
+ if (e.key === 'Delete') {
2972
+ return plain ? 'deleteContentForward' : word ? 'deleteWordForward' : line ? 'deleteSoftLineForward' : null;
2973
+ }
2974
+ return null;
2975
+ }
2976
+ function caretMoveForKey(e) {
2977
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
2978
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
2979
+ const extend = !!e.shiftKey;
2980
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
2981
+ if (!plain && !word) return null;
2982
+ return { dir: e.key === 'ArrowLeft' ? 'backward' : 'forward', unit: word ? 'word' : 'character', extend };
2983
+ }
2984
+ if (!plain) return null;
2985
+ if (e.key === 'Home' || e.key === 'End') return { dir: e.key === 'Home' ? 'backward' : 'forward', unit: 'lineboundary', extend };
2986
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return { dir: e.key === 'ArrowUp' ? 'backward' : 'forward', unit: 'line', extend };
2987
+ return null;
2988
+ }
2989
+ function isHeldTyping(en) {
2990
+ return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' ||
2991
+ (en.kind === 'key' && !caretMoveForKey(en));
2992
+ }
2993
+ function dropHeldTyping() {
2994
+ const kept = [];
2995
+ let carry = null;
2996
+ flightBuffer.forEach((en) => {
2997
+ if (isHeldTyping(en)) {
2998
+ if (en.pos) carry = en.pos;
2999
+ return;
3000
+ }
3001
+ if (en.pos) carry = null;
3002
+ else if (carry) { en.pos = carry; carry = null; }
3003
+ kept.push(en);
3004
+ });
3005
+ flightBuffer.length = 0;
3006
+ kept.forEach((en) => flightBuffer.push(en));
3007
+ }
3008
+ function flightHoldsKey(e) {
3009
+ if ((e.key === 'Enter' || e.key === 'Tab') && !e.ctrlKey && !e.metaKey && !e.altKey) return true;
3010
+ return !!(deletionForKey(e) || caretMoveForKey(e));
3011
+ }
3012
+
3013
+ // The native default of a deletion, against the restored selection: a
3014
+ // selection is deleted whole; a collapsed caret deletes one character, or
3015
+ // is first extended by a word / to the line boundary.
3016
+ function nativeFlightDeletion(t, target) {
3017
+ const sel = window.getSelection();
3018
+ if (!sel.rangeCount) return;
3019
+ const back = /Backward$/.test(t);
3020
+ const unit = /Word/.test(t) ? 'word' : (/Line/.test(t) ? 'lineboundary' : null);
3021
+ if (sel.isCollapsed && unit) {
3022
+ if (!sel.modify) return;
3023
+ sel.modify('extend', back ? 'backward' : 'forward', unit);
3024
+ // Nothing to delete in that direction: a collapsed 'delete' would take
3025
+ // a character instead.
3026
+ if (sel.isCollapsed) return;
3027
+ }
3028
+ snapBurstIfActive(target, 'replay-pre');
3029
+ document.execCommand(sel.isCollapsed && !back ? 'forwardDelete' : 'delete');
3030
+ snapBurstIfActive(target, 'replay');
3031
+ }
3032
+
3033
+ function replayFlightEntry(item, target) {
3034
+ if (item.kind === 'text') {
3035
+ // The browser's own insertText, not insertTextAtCaret(): it replaces a
3036
+ // selection the same way, and it also rebalances the whitespace around
3037
+ // the caret the way live typing does (measured: a held Ctrl+Backspace
3038
+ // leaves `ab` + NBSP, and only the native insertion of the next `e`
3039
+ // turns that back into `ab e`).
3040
+ snapBurstIfActive(target, 'replay-pre');
3041
+ if (/[\r\n]/.test(item.data) || !document.execCommand('insertText', false, item.data)) {
3042
+ insertTextAtCaret(item.data);
3043
+ }
3044
+ snapBurstIfActive(target, 'replay');
3045
+ } else if (item.kind === 'paste') {
3046
+ plainPasteArmed = item.plainOnly;
3047
+ routePaste(target, item.dt);
3048
+ } else if (item.kind === 'delete') {
3049
+ nativeFlightDeletion(item.inputType, target);
3050
+ } else if (item.kind === 'key') {
3051
+ const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
3052
+ ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
3053
+ target.dispatchEvent(ev);
3054
+ if (ev.defaultPrevented) return;
3055
+ const del = deletionForKey(item);
3056
+ if (del) { nativeFlightDeletion(del, target); return; }
3057
+ const move = caretMoveForKey(item);
3058
+ const sel = window.getSelection();
3059
+ if (move && sel.modify) sel.modify(move.extend ? 'extend' : 'move', move.dir, move.unit);
3060
+ }
3061
+ }
3062
+
3063
+ function flushFlightBuffer() {
3064
+ if (commitFlight || switching) return;
3065
+ let dropped = false;
3066
+ // Replay in order. A replayed key, or the focus change to a positioned
3067
+ // entry's surface, can start a new commit (or wait on one); the rest
3068
+ // waits and resumes from its endCommitFlight() or the focusin handlers.
3069
+ while (flightBuffer.length && !commitFlight && !switching) {
3070
+ const item = flightBuffer[0];
3071
+ let target = null;
3072
+ if (item.pos) {
3073
+ target = item.pos.surface && item.pos.surface.isConnected ? item.pos.surface : null;
3074
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3075
+ if (activeFlightSurface() !== target) {
3076
+ target.focus();
3077
+ if (commitFlight || switching) break;
3078
+ }
3079
+ restoreSelection(target, item.pos.anchor, item.pos.focus);
3080
+ } else {
3081
+ target = activeFlightSurface();
3082
+ const fb = flightFallback;
3083
+ if (!target && fb && fb.surface && fb.surface.isConnected) {
3084
+ fb.surface.focus();
3085
+ if (commitFlight || switching) break;
3086
+ restoreSelection(fb.surface, fb.anchor, fb.focus);
3087
+ flightFallback = null;
3088
+ target = activeFlightSurface();
3089
+ }
3090
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3091
+ }
3092
+ flightBuffer.shift();
3093
+ if (item.kind !== 'focus') {
3094
+ flightReplaying = true;
3095
+ try { replayFlightEntry(item, target); } finally { flightReplaying = false; }
3096
+ }
3097
+ }
3098
+ if (!flightBuffer.length && !commitFlight) flightFallback = null;
3099
+ if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
3100
+ }
3101
+
3102
+ // An IME composition cannot be cancelled (its beforeinput is not
3103
+ // cancelable), so the committed text is already in the DOM when
3104
+ // compositionend reports it. It is taken back out when it sits right
3105
+ // before the caret, which leaves the buffer its only owner — a render that
3106
+ // keeps this surface (a failed one, or a patch that does not cover it)
3107
+ // would otherwise show it twice once the replay lands. Returns false when
3108
+ // it cannot be found there; the caller holds it anyway, and the render
3109
+ // that replaces the surface is what removes the DOM copy.
3110
+ function takeBackComposedText(data) {
3111
+ const sel = window.getSelection();
3112
+ if (!data || !sel.rangeCount) return false;
3113
+ const r = sel.getRangeAt(0);
3114
+ if (!r.collapsed || r.startContainer.nodeType !== 3) return false;
3115
+ const node = r.startContainer;
3116
+ const start = r.startOffset - data.length;
3117
+ if (start < 0 || node.data.slice(start, r.startOffset) !== data) return false;
3118
+ node.deleteData(start, data.length);
3119
+ const c = document.createRange();
3120
+ c.setStart(node, start);
3121
+ c.collapse(true);
3122
+ sel.removeAllRanges();
3123
+ sel.addRange(c);
3124
+ return true;
2601
3125
  }
2602
3126
 
2603
3127
  function autoSize(ta) {
@@ -3021,8 +3545,6 @@
3021
3545
  // own comment for why only the more recent of the two stashes may
3022
3546
  // ever answer the next Ctrl+Z.
3023
3547
  dropDiscardedBurst();
3024
- // …and with the wave twin, for the same reason.
3025
- dropDiscardedWaveEdit();
3026
3548
  discardedRawEdit = {
3027
3549
  blockId, range, startLine, endLine, source,
3028
3550
  value: ta.value, caret: ta.selectionStart,
@@ -7271,8 +7793,6 @@
7271
7793
  // discardedRawEdit's own comment for why the two stashes must stay
7272
7794
  // mutually exclusive.
7273
7795
  dropDiscardedRawEdit();
7274
- // v3.4.0 batch3 Task 7: …and the wave twin, same rule, three stashes.
7275
- dropDiscardedWaveEdit();
7276
7796
  const blockEl = editEl.closest('.ed-block');
7277
7797
  if (!blockEl) return;
7278
7798
  const blockId = Number(blockEl.getAttribute('data-block-id'));
@@ -7509,11 +8029,19 @@
7509
8029
  commitMd = runMd;
7510
8030
  commitStart = range.startLine;
7511
8031
  commitEnd = range.endLine;
8032
+ // v3.13.1 T1 fix round 3: the whole run is rewritten — including any
8033
+ // provisional item still in it, which then gets a line of its own and
8034
+ // pushes the items after it down one. Keys held for those items find
8035
+ // them by this map (see flightLineMapOf()). Measured without it: a
8036
+ // failed Enter render, then `ab` replayed into the new item, then the
8037
+ // blur commit of this run — `Z` held for charlie landed in `ab`.
8038
+ pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
7512
8039
  }
7513
8040
  const liCommitResult = (commitMd === '')
7514
8041
  ? commitRangeRemoval({ lines, blocks, stack }, commitStart, commitEnd)
7515
8042
  : commitRangeEdit({ lines, blocks, stack }, commitStart, commitEnd, commitMd);
7516
8043
  if (liCommitResult.op === null) {
8044
+ pendingFlightLineMap = null;
7517
8045
  endBurstWithoutResolve();
7518
8046
  return true;
7519
8047
  }
@@ -7567,7 +8095,13 @@
7567
8095
  // commit '#'.repeat(depth) + ' ' with nothing after the space. Every
7568
8096
  // non-heading burst's `depth` is null (blockDepthOf() only computes it for
7569
8097
  // 'heading'), so it takes the plain result.md branch.
7570
- const newText = burst.depth === null ? result.md :
8098
+ // v3.13.1 T2 (F-1): a paragraph's text is escaped where it would read as
8099
+ // a block marker (`## `, `- `, `> `, `1. ` …), or the next render turns
8100
+ // the paragraph into that block. A heading's text sits after its own
8101
+ // `# ` and needs none; a table's rows START with `|`, so its markdown
8102
+ // must never reach the escape — hence the blockType test, not just depth.
8103
+ const newText = burst.depth === null
8104
+ ? (burst.blockType === 'paragraph' ? inlineMd.escapeBlockStarts(result.md) : result.md) :
7571
8105
  (result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
7572
8106
  // S1 removed the pre-per-li "a whole-list burst that serialized to ''"
7573
8107
  // branch that lived here alongside the dead 'list' serializer arm above.
@@ -8284,7 +8818,7 @@
8284
8818
  async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
8285
8819
  const span = runEls || [];
8286
8820
  const presetRange = opts && opts.presetRange;
8287
- const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8821
+ const { md, lineMeta } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8288
8822
  // The run's line range is read back off its own li blocks' ids — which
8289
8823
  // requires at least one li to still BE there. A caller whose mutation
8290
8824
  // removed the run's last item therefore captures the range BEFORE mutating
@@ -8313,6 +8847,10 @@
8313
8847
  endLine: Math.max(result.op.startLine - 1, result.op.endLine),
8314
8848
  delta: result.op.after.length - result.op.before.length }
8315
8849
  : undefined;
8850
+ // v3.13.1 T1 fix round 2: where each item of the run will start once this
8851
+ // commit lands, so a key held for an item of this very run — one the
8852
+ // split pushed down a line, or the provisional item itself — finds it.
8853
+ if (result.op !== null) pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
8316
8854
  const ok = await safeRerenderAll(editRange);
8317
8855
  if (!ok) {
8318
8856
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -8548,7 +9086,6 @@
8548
9086
  // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8549
9087
  // see discardedRawEdit's own comment.
8550
9088
  dropDiscardedRawEdit();
8551
- dropDiscardedWaveEdit();
8552
9089
  discardedBurst = {
8553
9090
  history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
8554
9091
  reverted: burstBaselineHtml(editEl),
@@ -8570,7 +9107,6 @@
8570
9107
  // v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
8571
9108
  // see discardedRawEdit's own comment.
8572
9109
  dropDiscardedRawEdit();
8573
- dropDiscardedWaveEdit();
8574
9110
  discardedBurst = {
8575
9111
  history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
8576
9112
  reverted: burstBaselineHtml(tableEl),
@@ -9063,6 +9599,28 @@
9063
9599
  //
9064
9600
  // Returns the new block element, or null when the caret is not inside
9065
9601
  // `blockEl`'s own surface (nothing mutated).
9602
+ // v3.13.1 T1: the item splitListItemAtCaret() just built is a plain div
9603
+ // until the commit's render replaces it — not editable, so the caret stayed
9604
+ // in the OLD item and anything typed before the render landed went there.
9605
+ // Arm it the way armEditables() arms an li (the same two writes), focus it
9606
+ // and put the caret at its start, where the render's own
9607
+ // focusBlockAtLine() will put it again.
9608
+ // The focusin this raises starts no burst: Enter's burst is still current
9609
+ // here, so that handler's `if (currentBurst) return;` returns.
9610
+ function armProvisionalLi(newLi) {
9611
+ const textEl = liTextEl(newLi);
9612
+ if (!textEl) return;
9613
+ textEl.setAttribute('contenteditable', 'true');
9614
+ textEl.classList.add('ed-wys-armed');
9615
+ textEl.focus();
9616
+ const range = document.createRange();
9617
+ range.selectNodeContents(textEl);
9618
+ range.collapse(true);
9619
+ const sel = window.getSelection();
9620
+ sel.removeAllRanges();
9621
+ sel.addRange(range);
9622
+ }
9623
+
9066
9624
  function splitListItemAtCaret(blockEl) {
9067
9625
  const sel = window.getSelection();
9068
9626
  if (!sel.rangeCount) return null;
@@ -9950,7 +10508,14 @@
9950
10508
  // splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
9951
10509
  // freshly-inserted sibling) and never touches the focused node's
9952
10510
  // attachment.
9953
- const newLi = mutateListRun(() => splitListItemAtCaret(li));
10511
+ // v3.13.1 T1: the new item is armed and focused inside the same span, so
10512
+ // the old item's focusout stays suppressed and the next keystroke is
10513
+ // already aimed at the new item while the commit below is in flight.
10514
+ const newLi = mutateListRun(() => {
10515
+ const n = splitListItemAtCaret(li);
10516
+ if (n) armProvisionalLi(n);
10517
+ return n;
10518
+ });
9954
10519
  if (!newLi) return true;
9955
10520
  run = listRunOf(newLi);
9956
10521
  // `li` had its text CUT IN TWO in the DOM; replaying its source would put
@@ -10002,7 +10567,6 @@
10002
10567
  dropDiscardedBurst();
10003
10568
  // v3.4.0 (Task 7 item 5): see startBurst()'s own comment just above.
10004
10569
  dropDiscardedRawEdit();
10005
- dropDiscardedWaveEdit();
10006
10570
  const tableEl = cellEl.closest('table');
10007
10571
  const blockEl = tableEl && tableEl.closest('.ed-block');
10008
10572
  if (!blockEl) return;
@@ -15451,6 +16015,10 @@
15451
16015
  mtimeMs = j.mtimeMs;
15452
16016
  stack.markSaved(token);
15453
16017
  setDirty();
16018
+ // Spec section 4.8: Ctrl+S inside the wave editor saves the whole file
16019
+ // and leaves the editor open; the toolbar's status slot is behind the
16020
+ // modal, so the result is said at the bottom as well.
16021
+ if (waveSaveStatusSink !== null) showWaveNotice('已儲存', null, null);
15454
16022
  // v3.4.0 §3: markSaved() just moved documentIsDirty() from true to
15455
16023
  // false, and the `save` button's own lit/grey state is read off
15456
16024
  // exactly that value (toolbarContext()'s `dirty` field) — without this
@@ -15622,11 +16190,6 @@
15622
16190
  // discardedRawEdit's own comment), so checking both in sequence never
15623
16191
  // answers a stale one while the other substrate holds the fresher discard.
15624
16192
  if (await restoreDiscardedRawEdit()) return;
15625
- // v3.4.0 batch3 Task 7: the wave twin. Third in the sequence for no reason
15626
- // other than that it is the newest; all three are mutually exclusive by
15627
- // construction (one editing session at a time) and each nulls its own slot
15628
- // before deciding, so order cannot make a stale one answer.
15629
- if (await restoreDiscardedWaveEdit()) return;
15630
16193
  // Resolve any open editor BEFORE the .content-replacing swap below —
15631
16194
  // see the `activeEditor` / switchAwayFrom() comments near its
15632
16195
  // declaration for why this is required (undo/redo silently detaching an
@@ -15635,6 +16198,24 @@
15635
16198
  // first), so the undo that follows targets whatever is now the newest
15636
16199
  // op — which, if an auto-commit just happened, IS that commit.
15637
16200
  if (!(await switchAwayFrom())) return;
16201
+ // Wave redesign Task 11 (spec section 4.8): the first Ctrl+Z after the wave
16202
+ // editor closed takes that whole session back as one step, while nothing
16203
+ // else has changed the document since. After the two stashes above on
16204
+ // purpose: either of them can only exist because something was opened and
16205
+ // discarded AFTER the close, so it is the newer thing to undo. And after
16206
+ // switchAwayFrom() (Ruling R25): typing still sitting in an open editor is
16207
+ // newer than the close too, and its commit replaces `lines`, which expires
16208
+ // the offer. Spent here whether or not it held — an offer that did not
16209
+ // hold now never will.
16210
+ if (waveUndoOffer !== null) {
16211
+ const held = waveUndoOfferValid();
16212
+ const offer = waveUndoOffer;
16213
+ waveUndoOffer = null;
16214
+ if (held) {
16215
+ const done = await undoWaveSession(offer);
16216
+ if (done !== 'missing') return;
16217
+ }
16218
+ }
15638
16219
  const prevLines = lines;
15639
16220
  const r = stack.undo(lines);
15640
16221
  if (!r) return;
@@ -15679,6 +16260,19 @@
15679
16260
 
15680
16261
  async function redo() {
15681
16262
  if (!(await switchAwayFrom())) return;
16263
+ // Wave redesign Task 11 (spec section 4.8: 可再被 Ctrl+Y 取消): while the
16264
+ // stack stands where the whole-session undo left it, Ctrl+Y re-applies the
16265
+ // session as one step (Ruling R24). A stack question, not an identity of
16266
+ // `lines`: an ordinary Ctrl+Z / Ctrl+Y over an earlier edit comes back to
16267
+ // the same stack, and the drawing still comes back whole after it. So the
16268
+ // offer is NOT spent when it does not hold; it simply stops holding for
16269
+ // good once a new edit clears the redo branch.
16270
+ if (waveRedoOffer !== null && waveRedoOfferValid()) {
16271
+ const ro = waveRedoOffer;
16272
+ waveRedoOffer = null;
16273
+ await redoWaveSession(ro);
16274
+ return;
16275
+ }
15682
16276
  const prevLines = lines;
15683
16277
  const r = stack.redo(lines);
15684
16278
  if (!r) return;
@@ -17453,6 +18047,38 @@
17453
18047
  e.target.classList.contains('ed-raw');
17454
18048
  if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
17455
18049
 
18050
+ // v3.13.1 T1: a key that edits or moves, pressed while a commit is in
18051
+ // flight, would act on DOM the render is about to replace. Held, in order
18052
+ // with the text around it, and replayed by flushFlightBuffer() through
18053
+ // this same handler (see `commitFlight`). An IME's own keys (isComposing)
18054
+ // are left alone. Escape drops everything held (R12) and then carries on
18055
+ // to its usual meaning. A standing table cell range keeps its own keys —
18056
+ // its Delete clears the range, which a held character delete would not.
18057
+ if (flightActive() && !e.isComposing && !cellRangeBurst()) {
18058
+ const flightSurface = flightSurfaceOf(e.target);
18059
+ if (flightSurface && e.key === 'Escape') { flightBuffer.length = 0; flightSelMoved = false; }
18060
+ // Ctrl/Cmd+Z with keys held: the most recent thing to undo is that
18061
+ // typing, which never reached the document — dropping it IS the undo,
18062
+ // and the commit in flight stays (the next Ctrl+Z, with nothing held,
18063
+ // undoes it as usual). Measured before: the undo reverted the Enter
18064
+ // underneath the held keys, and they replayed into the next item
18065
+ // (`abccharlie`). Held navigation (and a click's focus entry) is not
18066
+ // typing and stays; a dropped entry's position passes to the next kept
18067
+ // entry of its run.
18068
+ if (flightSurface && flightBuffer.some(isHeldTyping) && (e.ctrlKey || e.metaKey) && !e.shiftKey &&
18069
+ !e.altKey && (e.key === 'z' || e.key === 'Z')) {
18070
+ e.preventDefault();
18071
+ dropHeldTyping();
18072
+ return;
18073
+ }
18074
+ if (flightSurface && flightHoldsKey(e)) {
18075
+ e.preventDefault();
18076
+ holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
18077
+ ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
18078
+ return;
18079
+ }
18080
+ }
18081
+
17456
18082
  // Task 5: a table cell (class 'ed-wys-cell') owns its own Enter/Tab/
17457
18083
  // Escape/Ctrl+Z/Ctrl+Y contract, materially different from paragraph/
17458
18084
  // heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
@@ -17693,7 +18319,7 @@
17693
18319
  // top of each listener below — see handleTableCellFocusIn() above.
17694
18320
  document.addEventListener('focusin', async (e) => {
17695
18321
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
17696
- if (cellEl) { await handleTableCellFocusIn(cellEl); return; }
18322
+ if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
17697
18323
  const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
17698
18324
  if (!editEl) return;
17699
18325
  if (currentBurst && currentBurst.editEl === editEl) return; // already tracking
@@ -17704,7 +18330,17 @@
17704
18330
  // switchAwayFrom()'s single-flight `switching`. Await it before
17705
18331
  // starting a new burst so the two never race a concurrent commit.
17706
18332
  if (switching) await switching;
17707
- if (currentBurst) return; // a concurrent focusin already won the race
18333
+ // v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
18334
+ // arrow walk lands on the NEXT block from inside that same resolution, and
18335
+ // its own focusin is queued behind this one. Starting a burst here would
18336
+ // open it on a surface that no longer has focus and leave the one that
18337
+ // does without one: measured on base and head, Enter, `ab`, ArrowDown, `Z`
18338
+ // put `Z` in charlie's DOM, and the blur that followed committed nothing.
18339
+ if (document.body.contains(editEl) && !editEl.contains(document.activeElement)) {
18340
+ scheduleFlightFlush();
18341
+ return;
18342
+ }
18343
+ if (currentBurst) { scheduleFlightFlush(); return; } // a concurrent focusin already won the race
17708
18344
  let liveEditEl = editEl;
17709
18345
  if (!document.body.contains(editEl)) {
17710
18346
  // The awaited resolution above committed successfully and swapped the
@@ -17716,9 +18352,19 @@
17716
18352
  liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
17717
18353
  if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
17718
18354
  liveEditEl.focus();
18355
+ // v3.13.1 R11: focus() alone puts the caret at the surface's start, not
18356
+ // where the click that brought focus here put it. Measured on base and
18357
+ // head, both engines: edit A, click mid-B, type — the characters landed
18358
+ // at B's start; a drag or double-click selection collapsed. The
18359
+ // selection was read just before the render replaced B (lastRenderSel).
18360
+ if (lastRenderSel && lastRenderSel.surface === editEl) {
18361
+ restoreSelection(liveEditEl, lastRenderSel.anchor, lastRenderSel.focus);
18362
+ }
18363
+ scheduleFlightFlush();
17719
18364
  return;
17720
18365
  }
17721
18366
  startBurst(liveEditEl);
18367
+ scheduleFlightFlush();
17722
18368
  });
17723
18369
 
17724
18370
  document.addEventListener('focusout', (e) => {
@@ -17859,6 +18505,79 @@
17859
18505
  return currentBurst.history;
17860
18506
  }
17861
18507
 
18508
+ // v3.13.1 T1: a pointer press into a surface during a flight moves the
18509
+ // selection the user means the next held key for (see `flightSelMoved`).
18510
+ // A press anywhere else — the toolbar, the margin — is not one.
18511
+ // Capture phase: this runs before the press moves the selection, which is
18512
+ // what pins the opening run to where it was typed (see pinFirstFlightRun()).
18513
+ document.addEventListener('pointerdown', (e) => {
18514
+ if (!flightActive()) return;
18515
+ pinFirstFlightRun(selectionSnapshotIn(activeFlightSurface()));
18516
+ if (flightSurfaceOf(e.target)) flightSelMoved = true;
18517
+ }, true);
18518
+
18519
+ // v3.13.1 T1: the input half of the commit-in-flight guard (see
18520
+ // `commitFlight`). Registered ahead of the native-format listener below,
18521
+ // both in the capture phase, so a held keystroke never reaches it.
18522
+ //
18523
+ // `isComposing` insertText is skipped: an engine that reports a
18524
+ // composition's commit that way also reports it to compositionend below,
18525
+ // and the text must be held once.
18526
+ function flightSurfaceOfEvent(e) {
18527
+ return flightActive() ? flightSurfaceOf(e.target) : null;
18528
+ }
18529
+ // The text compositionend just held. An engine that inserts a
18530
+ // composition's commit AFTER compositionend (WebKit's
18531
+ // insertFromComposition) would put it in the DOM a second time; that
18532
+ // insertion is cancelled, or taken back out on its input event when it
18533
+ // cannot be cancelled. Cleared on the next task: the follow-up arrives in
18534
+ // the same one or not at all.
18535
+ let composedHeld = null;
18536
+ let composedTakeBack = null;
18537
+ document.addEventListener('beforeinput', (e) => {
18538
+ if (composedHeld !== null && e.data === composedHeld &&
18539
+ (e.inputType === 'insertFromComposition' || e.inputType === 'insertText')) {
18540
+ composedHeld = null;
18541
+ if (e.cancelable) e.preventDefault(); else composedTakeBack = e.data;
18542
+ return;
18543
+ }
18544
+ const surface = flightSurfaceOfEvent(e);
18545
+ if (!surface) return;
18546
+ const t = e.inputType;
18547
+ if ((t === 'insertText' || t === 'insertReplacementText') && !e.isComposing) {
18548
+ // insertReplacementText (autocorrect, spellcheck) carries its text in
18549
+ // dataTransfer in some engines and in `data` in others.
18550
+ const data = e.data != null ? e.data
18551
+ : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
18552
+ e.preventDefault();
18553
+ if (data) holdFlightEntry(surface, { kind: 'text', data });
18554
+ return;
18555
+ }
18556
+ // A deletion that did not come from a held key (the keyboard ones are
18557
+ // held at keydown — WebKit fires no beforeinput at all for Backspace on
18558
+ // an empty surface, measured): a context-menu delete, say.
18559
+ if (/^delete/.test(t) && t !== 'deleteByCut' && t !== 'deleteByDrag' && t !== 'deleteCompositionText') {
18560
+ e.preventDefault();
18561
+ holdFlightEntry(surface, { kind: 'delete', inputType: t });
18562
+ }
18563
+ }, true);
18564
+ document.addEventListener('input', (e) => {
18565
+ if (composedTakeBack !== null && e.data === composedTakeBack) takeBackComposedText(e.data);
18566
+ composedTakeBack = null;
18567
+ }, true);
18568
+ // Known limit: a composition still open when the flight ends is left to
18569
+ // the browser — its compositionend arrives with no flight to hold it, and
18570
+ // if the render replaced the surface mid-composition, what the engine does
18571
+ // with the rest of it is its own.
18572
+ document.addEventListener('compositionend', (e) => {
18573
+ const surface = flightSurfaceOfEvent(e);
18574
+ if (!surface || !e.data) return;
18575
+ takeBackComposedText(e.data);
18576
+ holdFlightEntry(surface, { kind: 'text', data: e.data });
18577
+ composedHeld = e.data;
18578
+ setTimeout(() => { composedHeld = null; }, 0);
18579
+ }, true);
18580
+
17862
18581
  // Capture phase, matching the phase the ordering above was measured in.
17863
18582
  document.addEventListener('beforeinput', (e) => {
17864
18583
  if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
@@ -17925,10 +18644,35 @@
17925
18644
  // wave field is not — this is here so the boundary is one predicate rather
17926
18645
  // than a property of which targets those two selectors happen to match.
17927
18646
  if (waveEditorIsModal()) return;
17928
- const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
17929
- if (cellEl) {
18647
+ // v3.13.1 T1 fix round 1: a paste while a commit is in flight is held like
18648
+ // typing (measured before: lost outright) and replayed through
18649
+ // routePaste(), the path a live paste takes. The clipboard is readable
18650
+ // only during the event, so its flavours are copied out here.
18651
+ const flightSurface = flightSurfaceOfEvent(e);
18652
+ if (flightSurface) {
17930
18653
  e.preventDefault();
17931
- const dt = e.clipboardData || window.clipboardData;
18654
+ holdFlightEntry(flightSurface, { kind: 'paste',
18655
+ dt: snapshotClipboard(e.clipboardData || window.clipboardData), plainOnly: plainPasteArmed });
18656
+ plainPasteArmed = false;
18657
+ return;
18658
+ }
18659
+ const target = e.target && e.target.closest &&
18660
+ (e.target.closest('.ed-wys-cell') || e.target.closest('.ed-wys-armed'));
18661
+ if (!target) return;
18662
+ e.preventDefault();
18663
+ routePaste(target, e.clipboardData || window.clipboardData);
18664
+ });
18665
+ function snapshotClipboard(dt) {
18666
+ const data = {};
18667
+ ['text/plain', 'text/html'].forEach((k) => {
18668
+ try { data[k] = dt ? dt.getData(k) || '' : ''; } catch (err) { data[k] = ''; }
18669
+ });
18670
+ const files = dt && dt.files ? Array.prototype.slice.call(dt.files) : [];
18671
+ return { files, types: Object.keys(data).filter((k) => data[k]), getData: (k) => data[k] || '' };
18672
+ }
18673
+ function routePaste(target, dt) {
18674
+ const cellEl = target.closest('.ed-wys-cell');
18675
+ if (cellEl) {
17932
18676
  const grid = dt ? clipboardGrid(dt) : null;
17933
18677
  if (grid && pasteGridIntoTable(cellEl, grid)) return;
17934
18678
  snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
@@ -17936,10 +18680,8 @@
17936
18680
  snapBurstIfActive(cellEl.closest('table'), 'paste');
17937
18681
  return;
17938
18682
  }
17939
- const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
18683
+ const editEl = target.closest('.ed-wys-armed');
17940
18684
  if (!editEl) return;
17941
- e.preventDefault();
17942
- const dt = e.clipboardData || window.clipboardData;
17943
18685
  // v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
17944
18686
  // (text/html -> markdown, an image, or plain text) and this routes the
17945
18687
  // answer. The TABLE-CELL branch above deliberately keeps its plain-text
@@ -17947,7 +18689,7 @@
17947
18689
  // pasted into one cell would be a nested structure the serializer has no
17948
18690
  // way to represent.
17949
18691
  handleArmedPaste(dt, editEl);
17950
- });
18692
+ }
17951
18693
 
17952
18694
  // Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
17953
18695
  // mousemove listener (never a per-block/per-boundary listener) drives
@@ -18760,16 +19502,18 @@
18760
19502
  //
18761
19503
  // Task 7's own fields, and why each is here rather than derived:
18762
19504
  // ops how many commits this session has pushed onto the document
18763
- // stack. Escape reverses exactly this many — see
18764
- // finishWaveSession().
19505
+ // stack since it last stood at its opening bytes. The net-zero
19506
+ // unwind (unwindWaveOps()) reverses exactly this many, and the
19507
+ // close's toast counts them (spec 4.8: N 處修改).
18765
19508
  // baseLines `lines` as it stood when the editor opened, and
18766
- // baseBlocks `blocks` likewise. Escape restores both BY VALUE instead of
18767
- // trusting the reversal's arithmetic to land back where it
18768
- // started; the two are the same answer, and one of them cannot
18769
- // be off by a line.
18770
- // baseSource the block body the store was opened over, i.e. what `lines`
18771
- // holds again once an Escape has run. The stash's staleness
18772
- // guard compares against it.
19509
+ // baseBlocks `blocks` likewise. The net-zero unwind restores both BY
19510
+ // VALUE instead of trusting the reversal's arithmetic to land
19511
+ // back where it started; the two are the same answer, and one
19512
+ // of them cannot be off by a line.
19513
+ // baseSource the block body the store was opened over: what the net-zero
19514
+ // unwind compares against, and what the whole-session undo
19515
+ // after closing writes back (waveUndoOffer). The seam's own
19516
+ // copy, never the store's rebased source (Rulings R16/R17).
18773
19517
  // unwritten this session has a gesture the file could not express (the
18774
19518
  // document-level flag `waveUnwrittenEdit` is the same fact; the
18775
19519
  // flag is what documentIsDirty() reads, this is what the
@@ -18780,8 +19524,8 @@
18780
19524
  // killed the arithmetic it replaces (fix 2 / MUST-FIX 1).
18781
19525
  // baseRedoTail the redo branch that existed before the session (fix 1 /
18782
19526
  // F3). The session's first commit clears it like any other edit;
18783
- // a session that is DISCARDED has to hand it back, or the user
18784
- // silently loses a redo to a document that ends up
19527
+ // a session that is unwound to nothing has to hand it back, or
19528
+ // the user silently loses a redo to a document that ends up
18785
19529
  // byte-identical.
18786
19530
  let waveSeam = null;
18787
19531
  // Gestures that arrived with no editor left to own them. Zero is the
@@ -18966,9 +19710,16 @@
18966
19710
  // button does nothing visible", never to a throw out of a click handler.
18967
19711
  if (!ui || !mods || !mods['wave-store.js'] || !mods['wave-codec.js'] ||
18968
19712
  !mods['wave-geometry.js'] || !mods['wave-draw.js'] ||
18969
- !mods['wave-panels.js']) return;
19713
+ !mods['wave-panels.js'] || !mods['wave-canvas.js']) return;
18970
19714
  if (waveEditor !== null) return;
18971
19715
  hideWaveEditBtn();
19716
+ // Spec section 6: no engine, no editor — a red card, never a blank canvas
19717
+ // and never a fallback drawing of our own (the engine IS the canvas).
19718
+ if (typeof WaveDrom === 'undefined' || WaveDrom === null ||
19719
+ typeof WaveDrom.RenderWaveForm !== 'function') {
19720
+ showError('波形引擎沒有載入,無法編輯', null, null);
19721
+ return;
19722
+ }
18972
19723
 
18973
19724
  // Settle the document BEFORE reading a single line out of it. An open
18974
19725
  // burst or block editor is committed here, deliberately in front of the
@@ -19048,6 +19799,13 @@
19048
19799
  geometry: mods['wave-geometry.js'],
19049
19800
  draw: mods['wave-draw.js'],
19050
19801
  panels: mods['wave-panels.js'],
19802
+ // The engine canvas (wave redesign Task 6a) and the theme API it
19803
+ // recolours with in dark (lib/theme/runtime.js).
19804
+ canvas: mods['wave-canvas.js'],
19805
+ theme: window.md2docTheme || null,
19806
+ // Task 10: the overflow rule's `suggestScale` (lib/wave-overflow.js),
19807
+ // for ⚙'s and the canvas chip's 「改每拍 K×」.
19808
+ waveOverflow: mods['wave-overflow.js'] || null,
19051
19809
  createStore: mods['wave-store.js'].createStore,
19052
19810
  // The store's own answer to「哪一條 lane 是哪一條」across one operation,
19053
19811
  // handed over rather than re-derived: the editor's cell cursor and its
@@ -19057,12 +19815,24 @@
19057
19815
  // another.
19058
19816
  laneTrace: mods['wave-store.js'].laneTrace,
19059
19817
  wavedrom: (typeof WaveDrom !== 'undefined') ? WaveDrom : null,
19818
+ // Spec section 4.8's two message levels, on this page's own banner:
19819
+ // 'error' is the red card that stays, 'notice' the 4-second toast.
19820
+ notify: (level, text, action) => {
19821
+ const label = action && action.label ? action.label : null;
19822
+ const run = action && typeof action.run === 'function' ? action.run : null;
19823
+ if (level === 'error') showError(text, label, run);
19824
+ else showWaveNotice(text, label !== null && run !== null ? label : null, run);
19825
+ },
19826
+ // The file's save state for the editor's title bar; later changes
19827
+ // arrive through `waveSaveStatusSink` (setDirty()).
19828
+ saveStatus: () => ({ dirty: documentIsDirty() }),
19829
+ saveKeyHint: SAVE_KEY_HINT,
19060
19830
  onGesture: (info) => {
19061
19831
  if (waveSeam === null) { waveStrayGestures++; return; }
19062
19832
  waveSeam.lastGesture = info;
19063
19833
  writeWaveGestureBack(info);
19064
19834
  },
19065
- onClose: (reason) => {
19835
+ onClose: () => {
19066
19836
  // Read the seam out and null BOTH before doing anything slow: the
19067
19837
  // predicate every global listener asks (`waveEditorIsModal()`) has to
19068
19838
  // answer "no" from this instant, and finishWaveSession() below awaits
@@ -19071,7 +19841,8 @@
19071
19841
  const seam = waveSeam;
19072
19842
  waveEditor = null;
19073
19843
  waveSeam = null;
19074
- finishWaveSession(seam, reason)
19844
+ waveSaveStatusSink = null;
19845
+ finishWaveSession(seam)
19075
19846
  // …and only then the keyboard, because the commit path RE-RENDERS
19076
19847
  // `.content` and every block element that existed when the overlay
19077
19848
  // was up is gone by the time that resolves. Focusing before the
@@ -19087,9 +19858,11 @@
19087
19858
  } catch (err) {
19088
19859
  waveEditor = null;
19089
19860
  waveSeam = null;
19861
+ waveSaveStatusSink = null;
19090
19862
  throw err;
19091
19863
  }
19092
19864
  waveEditor = editor;
19865
+ waveSaveStatusSink = typeof editor.setSaveStatus === 'function' ? editor.setSaveStatus : null;
19093
19866
  waveSeam = { blockId: id, startLine: body.startLine, endLine: body.endLine,
19094
19867
  lastGesture: null, ops: 0, unwritten: false,
19095
19868
  blockStartLine: block.startLine, blockEndLine: block.endLine,
@@ -19099,6 +19872,10 @@
19099
19872
  // A fresh session cannot inherit the previous one's unwritten work: that
19100
19873
  // session is over, and whatever it could not express went with it.
19101
19874
  waveUnwrittenEdit = false;
19875
+ // Nor the previous session's whole-session undo: a new session takes over
19876
+ // the block, and a Ctrl+Z after IT closes is about it alone.
19877
+ waveUndoOffer = null;
19878
+ waveRedoOffer = null;
19102
19879
  }
19103
19880
 
19104
19881
  // ── Task 7: one gesture, one write-back ────────────────────────────────
@@ -19119,16 +19896,15 @@
19119
19896
  // written directly. Two things fall out of that for free and are load-
19120
19897
  // bearing, not incidental: `stack.dirtyDepth` counts the waveform (so the
19121
19898
  // title dot, the save button and the beforeunload guard all see it without
19122
- // knowing what a waveform is), and Escape can take the session back off the
19123
- // stack rather than needing a stash of its own for the part that committed.
19899
+ // knowing what a waveform is), and a session that comes back to its opening
19900
+ // bytes can take its own ops back off the stack (unwindWaveOps()).
19124
19901
  //
19125
19902
  // What is deliberately NOT done here: re-rendering `.content`. The overlay
19126
19903
  // covers the document, a render replaces `.content` wholesale, and doing it
19127
19904
  // per paint would tear down and re-run WaveDrom on every gesture for a
19128
19905
  // picture nobody can see. `.content` therefore lags `lines` for exactly as
19129
- // long as the modal is up, and the session's close settles it — a commit by
19130
- // rendering, an Escape by putting `lines` back to what `.content` is still
19131
- // showing.
19906
+ // long as the modal is up, and the session's close settles it by rendering
19907
+ // (every route out of the editor keeps the drawing — spec section 4.8).
19132
19908
  function writeWaveGestureBack(info) {
19133
19909
  const seam = waveSeam;
19134
19910
  if (seam === null || !info || !info.patch) return;
@@ -19222,7 +19998,8 @@
19222
19998
  //
19223
19999
  // That is a FAMILY, not one caller: `addLaneFromToolbar` and 複製 have
19224
20000
  // written over the same refusal since before Task 16, and the rail's drag
19225
- // drop announces nothing at all. Re-ordering those says is behaviour, and
20001
+ // drop announced nothing at all (since v3.13 the ⠿ grip's drop goes
20002
+ // through `moveLaneTo`, which says where the lane landed). Re-ordering those says is behaviour, and
19226
20003
  // it is parked as a next-version follow-up rather than done here. So the
19227
20004
  // banner below is not a nicety layered on a status line that already said
19228
20005
  // it — it is the only part of the refusal that cannot be clobbered.
@@ -19357,12 +20134,6 @@
19357
20134
  });
19358
20135
  }
19359
20136
 
19360
- /**
19361
- * Settle a wave session that has just gone off screen.
19362
- *
19363
- * `reason` is wave-ui's, not inferred here: 'escape' means the user asked for
19364
- * the session to be thrown away, anything else means they kept it.
19365
- */
19366
20137
  /**
19367
20138
  * Give the keyboard back to the block the editor was opened over.
19368
20139
  *
@@ -19397,60 +20168,16 @@
19397
20168
  setBlockSelection({ anchorLine: b.startLine, focusLine: b.startLine });
19398
20169
  }
19399
20170
 
19400
- async function finishWaveSession(seam, reason) {
20171
+ /**
20172
+ * Settle a wave session that has just gone off screen.
20173
+ *
20174
+ * Every route out of the editor — ✕, 完成, Esc — keeps the session (spec
20175
+ * section 4.8): each gesture was committed as it was drawn, so all that is
20176
+ * left is the one render `.content` has been waiting for, and the offer
20177
+ * that lets the next Ctrl+Z take the whole session back at once.
20178
+ */
20179
+ async function finishWaveSession(seam) {
19401
20180
  if (seam === null) return;
19402
- if (reason === 'escape') {
19403
- // Escape DISCARDS — the same word it means everywhere else in v3.3.0 —
19404
- // and here that has to reach the document stack, because the session has
19405
- // been committing all along.
19406
- const painted = bodyNow(seam);
19407
- const hadOps = seam.ops > 0;
19408
- if (!unwindWaveOps(seam)) {
19409
- // A Ctrl+S landed inside this session, so its commits cannot simply be
19410
- // popped — see unwindWaveOps() for the measured failure that produces.
19411
- // A revert COMMIT is the honest answer instead: the document goes back
19412
- // to where the session found it, and the stack only ever grows, so
19413
- // `dirtyDepth` stays strictly positive and can never walk back through
19414
- // zero over a document that differs from disk. The cost is a `●` the
19415
- // user clears with one Ctrl+S — which is TRUE, because disk is still
19416
- // holding the drawing they just discarded.
19417
- const result = commitRangeEdit({ lines, blocks, stack },
19418
- seam.startLine, seam.endLine, seam.baseSource);
19419
- if (result.op !== null) lines = result.lines;
19420
- blocks = seam.baseBlocks;
19421
- seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
19422
- // fix 2 (R1): the redo branch, on THIS path too. Both Escape branches
19423
- // end with `lines` byte-identical to `seam.baseLines` — only the seam
19424
- // range is ever written during a session — so the pre-session redo ops
19425
- // still address the document they were recorded against, and a user who
19426
- // lands here is otherwise in exactly F3's position: a byte-identical
19427
- // document with their redo silently eaten. AFTER the commit, because
19428
- // `push()` is what cleared it.
19429
- stack.setRedoTail(seam.baseRedoTail);
19430
- }
19431
- if (hadOps && painted !== seam.baseSource) {
19432
- // …and the discard is recoverable by exactly one Ctrl+Z. See
19433
- // discardedWaveEdit's own comment for which of discardedBurst's rules
19434
- // this reuses and which of them cannot apply here.
19435
- dropDiscardedBurst();
19436
- dropDiscardedRawEdit();
19437
- discardedWaveEdit = {
19438
- blockId: seam.blockId,
19439
- blockStartLine: seam.blockStartLine, blockEndLine: seam.blockEndLine,
19440
- startLine: seam.startLine, endLine: seam.endLine,
19441
- source: seam.baseSource, painted: painted,
19442
- };
19443
- }
19444
- // Work the file could not hold goes with the session it belonged to.
19445
- // The banner that said so is deliberately NOT dismissed here: it is the
19446
- // only record the user has that something did not survive.
19447
- waveUnwrittenEdit = false;
19448
- setDirty();
19449
- // No render. `.content` was never repainted while the overlay was up, so
19450
- // it is still showing exactly the base document just restored — rendering
19451
- // would be a round trip to say nothing.
19452
- return;
19453
- }
19454
20181
  if (seam.unwritten) {
19455
20182
  // Kept, with a hole in it. The user is told once more on the way out,
19456
20183
  // because the status line they were reading has just been removed from
@@ -19463,6 +20190,8 @@
19463
20190
  waveUnwrittenEdit = false;
19464
20191
  setDirty();
19465
20192
  }
20193
+ // Nothing written (or the drawing came back to its opening bytes, which
20194
+ // unwound the ops): no render, no toast, no offer.
19466
20195
  if (seam.ops === 0) return;
19467
20196
  // fix 1 (F2): `blocks` is settled from the session's own arithmetic BEFORE
19468
20197
  // the render, not instead of it. A successful render overwrites it with the
@@ -19497,61 +20226,150 @@
19497
20226
  '畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
19498
20227
  'Ctrl+Z 可以撤銷)。', null, null);
19499
20228
  }
20229
+ // Wave redesign Task 11: the whole-session undo offer (see waveUndoOffer's
20230
+ // own comment). Made AFTER the render rather than before it: an offer used
20231
+ // while the close render is still in flight would start a second render
20232
+ // racing this one. `lines` does not move across a render, so this is the
20233
+ // array the close left either way. The keyboard's way in is Ctrl+Z (spec
20234
+ // section 7.3: the keyboard goes back to the block, not to the toast); the
20235
+ // toast's 整段復原 is the pointer's.
20236
+ //
20237
+ // Ruling R24: the offer pops the session's own entries, so it is made only
20238
+ // when they are exactly the top `ops` of the stack. When they are not
20239
+ // (nothing known reaches that today), the close still says what changed,
20240
+ // without promising a one-step undo it could not keep.
20241
+ waveRedoOffer = null;
20242
+ if (stack.depth === seam.baseDepth + seam.ops) {
20243
+ waveUndoOffer = { blockId: seam.blockId, startLine: seam.startLine, endLine: seam.endLine,
20244
+ baseSource: seam.baseSource, baseDepth: seam.baseDepth,
20245
+ lines: lines, depth: stack.depth, count: seam.ops };
20246
+ showWaveNotice('波形已更新(' + seam.ops + ' 處修改)· Ctrl+Z 整段復原', '整段復原',
20247
+ () => { revertWaveSessionFromToast(); });
20248
+ } else {
20249
+ showWaveNotice('波形已更新(' + seam.ops + ' 處修改)', null, null);
20250
+ }
20251
+ }
20252
+
20253
+ /** Whether the closed session's whole-session undo still describes the document. */
20254
+ function waveUndoOfferValid() {
20255
+ return waveOfferHolds(waveUndoOffer, { lines: lines, depth: stack.depth, burstOpen: burstHasUncommittedEdit() });
20256
+ }
20257
+ /** Whether the stack still stands where the whole-session undo left it, so
20258
+ * Ctrl+Y can re-apply the session as one step. */
20259
+ function waveRedoOfferValid() {
20260
+ return waveRedoHolds(waveRedoOffer, { depth: stack.depth, tail: stack.redoTail(), burstOpen: burstHasUncommittedEdit() });
20261
+ }
20262
+
20263
+ /**
20264
+ * The toast's 整段復原: the same step as Ctrl+Z, but only while the offer
20265
+ * still holds. A press on a toast that outlived its offer must not fall
20266
+ * through to an ordinary undo of somebody else's edit. An open editor is
20267
+ * committed first, exactly as undo() does (Ruling R25).
20268
+ */
20269
+ async function revertWaveSessionFromToast() {
20270
+ if (!(await switchAwayFrom())) return;
20271
+ if (!waveUndoOfferValid()) {
20272
+ waveUndoOffer = null;
20273
+ showNotice('文件在關閉後又改過了,整段復原已經失效;請用 Ctrl+Z 一步一步退');
20274
+ return;
20275
+ }
20276
+ const offer = waveUndoOffer;
20277
+ waveUndoOffer = null;
20278
+ await undoWaveSession(offer);
19500
20279
  }
19501
20280
 
19502
20281
  /**
19503
- * Ctrl+Z straight after an Escape out of the wave editor: put the discarded
19504
- * drawing back and re-open the editor over it.
20282
+ * Spec section 4.8 / Ruling R24: take the closed session back as ONE user
20283
+ * step by popping its own `count` entries off the document stack, then
20284
+ * render once.
19505
20285
  *
19506
- * The restore is an ordinary commit, not a special case — so the reopened
19507
- * session opens over a document that already holds the drawing, one more
19508
- * Ctrl+Z steps back over the restore itself, and Ctrl+S saves it. That is
19509
- * the difference from restoreDiscardedRawEdit(), whose stashed value lives
19510
- * in a textarea until a later blur commits it: there is no "uncommitted
19511
- * surface" shape available here, because the wave editor's surface IS the
19512
- * document.
20286
+ * A pop, not a commit. The first cut wrote the opening body back as an
20287
+ * ordinary commit, and the stack then ping-ponged: the next Ctrl+Z popped
20288
+ * that commit and the whole drawing came back, so an edit made before the
20289
+ * session was count + 3 presses away. Popping keeps the stack linear (the
20290
+ * next Ctrl+Z is whatever came before the session), the save marker honest,
20291
+ * and Ctrl+Y re-applies the same entries (redoWaveSession()).
19513
20292
  *
19514
- * Staleness is asked at the `lines`/`blocks` level, the same level
19515
- * restoreDiscardedRawEdit() asks at and for the same reason — the overlay
19516
- * was removed from the DOM by `close()`, so there is no node whose presence
19517
- * could answer it.
20293
+ * Why popping exactly `count` entries is the session: the caller has checked
20294
+ * the offer holds (`lines` is still the array the close left and the stack
20295
+ * the depth it left), and the offer was only made when that depth was
20296
+ * `baseDepth + ops`. The opening body is still checked after the pops — a
20297
+ * gesture that ever wrote other than one entry would show up there — and a
20298
+ * mismatch puts the entries back and says so instead of writing anything.
20299
+ *
20300
+ * Returns 'done', 'refused' (nothing changed; already said) or 'missing'
20301
+ * (the block is not in the map; said, and the caller undoes one step as
20302
+ * usual).
19518
20303
  */
19519
- async function restoreDiscardedWaveEdit() {
19520
- const d = discardedWaveEdit;
19521
- discardedWaveEdit = null;
19522
- if (!d) return false;
19523
- const b = blocks.find((x) => x.id === d.blockId);
19524
- if (!b || b.startLine !== d.blockStartLine || b.endLine !== d.blockEndLine) return false;
19525
- if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
20304
+ async function undoWaveSession(offer) {
20305
+ const b = blocks.find((x) => x.id === offer.blockId);
20306
+ if (!b) {
20307
+ showNotice('找不到剛才編輯的那張波形,沒辦法整段退回;這一下 Ctrl+Z 照常退一步');
20308
+ return 'missing';
20309
+ }
19526
20310
  const prevLines = lines;
19527
- const result = commitRangeEdit({ lines, blocks, stack },
19528
- d.startLine, d.endLine, d.painted);
19529
- if (result.op === null) return false;
19530
- lines = result.lines;
19531
- // `blocks` deliberately not reassigned — the same shape every other
19532
- // commit-then-render site in this file has: the render that follows is what
19533
- // replaces it, and rollbackFailedRender() below restores `lines` only, so
19534
- // an assignment here would survive a failed render as the one value nothing
19535
- // puts back.
19536
- setDirty();
19537
- const ok = await safeRerenderAll({
19538
- startLine: d.startLine,
19539
- endLine: d.startLine + d.painted.split('\n').length - 1,
19540
- delta: result.op.after.length - result.op.before.length,
20311
+ const popped = [];
20312
+ while (popped.length < offer.count) {
20313
+ const r = stack.undo(lines);
20314
+ if (!r) break;
20315
+ lines = r.lines;
20316
+ popped.push(r.op);
20317
+ }
20318
+ // `b` is from the map the close render made: the fence line has not moved
20319
+ // (every gesture rewrote the body only), so the body starts right after it.
20320
+ const n = offer.baseSource.split('\n').length;
20321
+ const back = popped.length === offer.count &&
20322
+ lines.slice(b.startLine, b.startLine + n).join('\n') === offer.baseSource;
20323
+ if (back) setDirty();
20324
+ const ok = back && await safeRerenderAll({
20325
+ startLine: b.startLine + 1, endLine: b.startLine + n, delta: lines.length - prevLines.length,
19541
20326
  });
19542
20327
  if (!ok) {
19543
- lines = rollbackFailedRender({ lines, stack }, result, prevLines);
20328
+ for (let i = 0; i < popped.length; i++) lines = stack.redo(lines).lines;
20329
+ setDirty();
20330
+ if (!back) {
20331
+ showNotice('這次的波形修改沒辦法整段退回(文件的復原紀錄跟關閉時對不上),所以沒有動;' +
20332
+ '請用 Ctrl+Z 一步一步退');
20333
+ }
20334
+ return 'refused';
20335
+ }
20336
+ waveRedoOffer = { baseDepth: offer.baseDepth, ops: popped, offer: offer };
20337
+ showWaveNotice('已退回這次的波形修改', null, null);
20338
+ return 'done';
20339
+ }
20340
+
20341
+ /**
20342
+ * Ctrl+Y while the stack stands where undoWaveSession() left it: re-apply
20343
+ * the session's entries with stack.redo(), as one step, and render once. The
20344
+ * document is then exactly where the close left it, so the whole-session
20345
+ * undo is on offer again. waveRedoHolds() has checked that the top of the
20346
+ * redo branch is those very entries, so the redos cannot come up short; if
20347
+ * the render fails they are undone again.
20348
+ */
20349
+ async function redoWaveSession(ro) {
20350
+ const prevLines = lines;
20351
+ let done = 0;
20352
+ while (done < ro.ops.length) {
20353
+ const r = stack.redo(lines);
20354
+ if (!r) break;
20355
+ lines = r.lines;
20356
+ done++;
20357
+ }
20358
+ let ok = done === ro.ops.length;
20359
+ if (ok) {
20360
+ setDirty();
20361
+ const b = blocks.find((x) => x.id === ro.offer.blockId);
20362
+ ok = await safeRerenderAll(b ? { startLine: b.startLine + 1, endLine: b.startLine + 1,
20363
+ delta: lines.length - prevLines.length } : undefined);
20364
+ }
20365
+ if (!ok) {
20366
+ for (let i = 0; i < done; i++) lines = stack.undo(lines).lines;
19544
20367
  setDirty();
19545
20368
  return false;
19546
20369
  }
19547
- // Re-open over the block the render just rebuilt. `openWaveEditor()` wants
19548
- // something INSIDE the block and resolves the block with `closest()`, and
19549
- // `closest()` matches the element itself — so the block element is a valid
19550
- // argument and does not depend on WaveDrom having finished painting a
19551
- // `.wavedrom-diagram` into it yet.
19552
- const blockEl = blockElById(d.blockId);
19553
- if (!blockEl) return true; // the drawing is back in the document either way
19554
- await openWaveEditor(blockEl);
20370
+ waveUndoOffer = Object.assign({}, ro.offer, { lines: lines, depth: stack.depth });
20371
+ showWaveNotice('波形修改已恢復 · Ctrl+Z 整段復原', '整段復原',
20372
+ () => { revertWaveSessionFromToast(); });
19555
20373
  return true;
19556
20374
  }
19557
20375
 
@@ -19564,13 +20382,16 @@
19564
20382
  return {
19565
20383
  open: waveEditor !== null,
19566
20384
  stray: waveStrayGestures,
19567
- // Task 7's three: whether the document currently believes it has
20385
+ // Task 7's two: whether the document currently believes it has
19568
20386
  // anything unsaved (the thing the Reload button's beforeunload guard
19569
- // asks), whether this session has work the file could not hold, and
19570
- // whether a discarded session is sitting there waiting for a Ctrl+Z.
20387
+ // asks) and whether this session has work the file could not hold.
20388
+ // Task 11's two: whether a closed session's whole-session undo is on
20389
+ // offer and would hold now, and the same for its Ctrl+Y.
19571
20390
  dirty: documentIsDirty(),
19572
20391
  unwritten: waveUnwrittenEdit,
19573
- stashed: discardedWaveEdit !== null,
20392
+ undoOffer: waveUndoOffer === null ? null
20393
+ : { count: waveUndoOffer.count, blockId: waveUndoOffer.blockId, holds: waveUndoOfferValid() },
20394
+ redoOffer: waveRedoOffer === null ? null : { holds: waveRedoOfferValid() },
19574
20395
  seam: waveSeam === null ? null : {
19575
20396
  blockId: waveSeam.blockId,
19576
20397
  startLine: waveSeam.startLine,