@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.
- package/bin/md2doc.js +6 -0
- package/lib/editor/client.js +1015 -194
- package/lib/editor/inline-md.js +45 -1
- package/lib/editor/list-md.js +8 -1
- package/lib/editor/server.js +5 -0
- package/lib/editor/wave-canvas.js +301 -0
- package/lib/editor/wave-codec.js +417 -11
- package/lib/editor/wave-draw.js +209 -1202
- package/lib/editor/wave-geometry.js +145 -10
- package/lib/editor/wave-panels.js +907 -918
- package/lib/editor/wave-store.js +486 -91
- package/lib/editor/wave-ui.js +3588 -2232
- package/lib/md2doc.js +765 -534
- package/lib/theme/runtime.js +296 -29
- package/lib/theme/tokens.js +3 -0
- package/lib/wave-overflow.js +107 -0
- package/package.json +3 -3
- package/scripts/release-check.js +60 -0
package/lib/editor/client.js
CHANGED
|
@@ -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
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17929
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
18764
|
-
//
|
|
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.
|
|
18767
|
-
// trusting the reversal's arithmetic to land
|
|
18768
|
-
// started; the two are the same answer, and one
|
|
18769
|
-
// be off by a line.
|
|
18770
|
-
// baseSource the block body the store was opened over
|
|
18771
|
-
//
|
|
18772
|
-
//
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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
|
|
19123
|
-
//
|
|
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
|
|
19130
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
19504
|
-
*
|
|
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
|
-
*
|
|
19507
|
-
*
|
|
19508
|
-
*
|
|
19509
|
-
*
|
|
19510
|
-
*
|
|
19511
|
-
*
|
|
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
|
-
*
|
|
19515
|
-
*
|
|
19516
|
-
*
|
|
19517
|
-
*
|
|
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
|
|
19520
|
-
const
|
|
19521
|
-
|
|
19522
|
-
|
|
19523
|
-
|
|
19524
|
-
|
|
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
|
|
19528
|
-
|
|
19529
|
-
|
|
19530
|
-
|
|
19531
|
-
|
|
19532
|
-
|
|
19533
|
-
|
|
19534
|
-
//
|
|
19535
|
-
//
|
|
19536
|
-
|
|
19537
|
-
const
|
|
19538
|
-
startLine
|
|
19539
|
-
|
|
19540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19548
|
-
|
|
19549
|
-
|
|
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
|
|
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)
|
|
19570
|
-
// whether a
|
|
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
|
-
|
|
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,
|