@helping-ai-workflow/md2doc 3.13.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +2768 -202
- package/lib/editor/inline-md.js +66 -1
- package/lib/editor/list-md.js +8 -1
- package/lib/editor/toolbar-model.js +11 -1
- package/lib/md2doc.js +453 -83
- package/lib/theme/runtime.js +279 -27
- package/lib/theme/tokens.js +3 -0
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -236,6 +236,57 @@
|
|
|
236
236
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// v3.14.0 T1: where a transient paragraph's lines land when it is
|
|
240
|
+
// committed. `anchorBlockId` is the real block the transient sits after
|
|
241
|
+
// (the `data-block-id` of transientAnchorEl() in the browser half), or
|
|
242
|
+
// null when nothing precedes it. Three cases, each ONE op:
|
|
243
|
+
// * anchored — exactly commitBlockInsertion(), blank-line rules included.
|
|
244
|
+
// * null with blocks present — the transient opens the document. The
|
|
245
|
+
// first block's start line is replaced by [...newLines, '', that line]
|
|
246
|
+
// through commitRangeEdit(), and EVERY block is shifted: commitRangeEdit
|
|
247
|
+
// shifts only the blocks after its anchor, and here the anchor itself
|
|
248
|
+
// (the first block) moves too.
|
|
249
|
+
// * no blocks at all — an empty or whitespace-only file. Its whole line
|
|
250
|
+
// range is replaced by [...newLines, ''], so the file ends with exactly
|
|
251
|
+
// one newline whatever blank lines it held.
|
|
252
|
+
// Returns { lines, blocks, op, newStartLine }; op is null only for a
|
|
253
|
+
// refusal (commitBlockInsertion()'s inverted-anchor case).
|
|
254
|
+
function commitInsertionAt(state, anchorBlockId, newLines) {
|
|
255
|
+
if (anchorBlockId !== null && anchorBlockId !== undefined) {
|
|
256
|
+
return commitBlockInsertion(state, anchorBlockId, newLines);
|
|
257
|
+
}
|
|
258
|
+
if (!state.blocks.length) {
|
|
259
|
+
if (!state.lines.length) {
|
|
260
|
+
const after = newLines.concat(['']);
|
|
261
|
+
const op = { startLine: 1, endLine: 0, before: [], after };
|
|
262
|
+
const r = ops.insertLines(state.lines, 0, after);
|
|
263
|
+
state.stack.push(op);
|
|
264
|
+
return { lines: r.lines, blocks: state.blocks, op, newStartLine: 1 };
|
|
265
|
+
}
|
|
266
|
+
const z = commitRangeEdit(state, 1, state.lines.length, newLines.concat(['']).join('\n'));
|
|
267
|
+
return { lines: z.lines, blocks: z.blocks, op: z.op, newStartLine: 1 };
|
|
268
|
+
}
|
|
269
|
+
const first = state.blocks[0];
|
|
270
|
+
const at = first.startLine;
|
|
271
|
+
const r = commitRangeEdit(state, at, at, newLines.concat(['', state.lines[at - 1]]).join('\n'));
|
|
272
|
+
if (r.op === null) return { lines: r.lines, blocks: r.blocks, op: null, newStartLine: null };
|
|
273
|
+
const delta = r.lines.length - state.lines.length;
|
|
274
|
+
const blocks = state.blocks.map((b) =>
|
|
275
|
+
Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta }));
|
|
276
|
+
return { lines: r.lines, blocks, op: r.op, newStartLine: at };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// v3.14.0 T2: Enter mid-paragraph (or mid-heading). The block's lines are
|
|
280
|
+
// replaced by the left half, one blank line and the right half — ONE op
|
|
281
|
+
// through commitRangeEdit(), so one Ctrl+Z puts the paragraph back.
|
|
282
|
+
// `rightStartLine` is where the right half starts once this lands.
|
|
283
|
+
function commitSplit(state, blockId, leftText, rightText) {
|
|
284
|
+
const block = state.blocks.find((b) => b.id === blockId);
|
|
285
|
+
const r = commitRangeEdit(state, block.startLine, block.endLine, leftText + '\n\n' + rightText);
|
|
286
|
+
return { lines: r.lines, blocks: r.blocks, op: r.op,
|
|
287
|
+
rightStartLine: block.startLine + leftText.split('\n').length + 1 };
|
|
288
|
+
}
|
|
289
|
+
|
|
239
290
|
// ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
|
|
240
291
|
//
|
|
241
292
|
// A move is ONE commitRangeEdit over
|
|
@@ -742,7 +793,7 @@
|
|
|
742
793
|
}
|
|
743
794
|
|
|
744
795
|
if (typeof module === 'object' && module.exports) {
|
|
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 };
|
|
796
|
+
module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
|
|
746
797
|
return; // node: pure core only
|
|
747
798
|
}
|
|
748
799
|
|
|
@@ -1005,8 +1056,32 @@
|
|
|
1005
1056
|
// instead of leaving the skeleton on disk. Editing ANYTHING clears it
|
|
1006
1057
|
// (see each call site below) — from that point on the block is a normal,
|
|
1007
1058
|
// permanently-committed one like any other.
|
|
1059
|
+
// Since v3.14.0 (T1) the 段落 kind never reaches this: a new paragraph is a
|
|
1060
|
+
// transient that writes nothing in the first place (see createTransient()),
|
|
1061
|
+
// so four skeletons — heading, list, table, code — use it today.
|
|
1008
1062
|
let pristineInsert = null; // { blockId } | null
|
|
1009
1063
|
|
|
1064
|
+
// v3.14.0 T1: the transient empty paragraph (spec §2) — an id-less
|
|
1065
|
+
// `.ed-block[data-ed-transient]` that writes no byte until it has content.
|
|
1066
|
+
// `transient` is the one that is on screen (at most one, D3), or null:
|
|
1067
|
+
// { el, surface, byEnter, anchorCaret, stayEl }. A render rebuilds `.content` and
|
|
1068
|
+
// drops it (applyFullRender() nulls this). `transientLandedLine` is the
|
|
1069
|
+
// start line its last successful commit landed on (R8) — read by callers
|
|
1070
|
+
// that commit a transient and then act on the block it became.
|
|
1071
|
+
let transient = null;
|
|
1072
|
+
let transientLandedLine = null;
|
|
1073
|
+
// Fix round 1 (I2): the line shifts landed transient commits made, so a
|
|
1074
|
+
// gesture that captured a block's identity before one can still find it
|
|
1075
|
+
// (identityStartLineNow()).
|
|
1076
|
+
const transientCommits = { seq: 0, log: [] };
|
|
1077
|
+
// v3.14.0 T2 (D7): a structural edit whose render failed and that the
|
|
1078
|
+
// screen still shows — see retryPendingStructure(). null, or
|
|
1079
|
+
// { range, before, surfaces, serialize }.
|
|
1080
|
+
let pendingStructure = null;
|
|
1081
|
+
// D13 / R1: the hint on an empty paragraph. "/" does not exist yet, so the
|
|
1082
|
+
// hint does not name it; v3.15.0 changes this one constant.
|
|
1083
|
+
const PLACEHOLDER_TEXT = '輸入文字';
|
|
1084
|
+
|
|
1010
1085
|
// Task 5 fix (found via a standalone repro harness — see the task-5
|
|
1011
1086
|
// report): a table mutation that DETACHES the focused cell — whether by
|
|
1012
1087
|
// reassigning tableEl.innerHTML wholesale or by re-parenting the cell/row
|
|
@@ -1110,7 +1185,9 @@
|
|
|
1110
1185
|
let switching = null;
|
|
1111
1186
|
function switchAwayFrom() {
|
|
1112
1187
|
if (switching) return switching;
|
|
1113
|
-
|
|
1188
|
+
// v3.13.1 T1: a held-key replay that waited on this resolution resumes
|
|
1189
|
+
// once it is over (scheduleFlightFlush() is a no-op with nothing held).
|
|
1190
|
+
switching = resolveOpenSession().finally(() => { switching = null; scheduleFlightFlush(); });
|
|
1114
1191
|
return switching;
|
|
1115
1192
|
}
|
|
1116
1193
|
|
|
@@ -1126,6 +1203,9 @@
|
|
|
1126
1203
|
// open with its edit intact, banner already shown, caller must abandon
|
|
1127
1204
|
// whatever it was about to do).
|
|
1128
1205
|
async function resolveOpenSession() {
|
|
1206
|
+
// v3.14.0 T2 (D7): a structure whose render failed goes first — anything
|
|
1207
|
+
// committed before it would render, and a render drops its screen half.
|
|
1208
|
+
if (pendingStructure && !(await retryPendingStructure())) return false;
|
|
1129
1209
|
if (currentBurst) {
|
|
1130
1210
|
const ok = await resolveBurst();
|
|
1131
1211
|
if (!ok) return false;
|
|
@@ -1190,7 +1270,11 @@
|
|
|
1190
1270
|
// The distance was a second record of "are we standing where we saved";
|
|
1191
1271
|
// `UndoStack` now answers that question itself, and a boolean has no zero
|
|
1192
1272
|
// to walk through. See lineops.js's own comment for the full story.
|
|
1193
|
-
|
|
1273
|
+
// v3.14.0 T6 (R34): a structure whose render failed is on screen and not
|
|
1274
|
+
// in `lines` (pendingStructure) — unsaved work like an open burst's text.
|
|
1275
|
+
// Measured before: a failed split, then the file changed on disk, and
|
|
1276
|
+
// onDiskMtime() reloaded the page silently, the split gone.
|
|
1277
|
+
return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null;
|
|
1194
1278
|
}
|
|
1195
1279
|
|
|
1196
1280
|
function setDirty() {
|
|
@@ -1685,6 +1769,11 @@
|
|
|
1685
1769
|
// through explicitly. Passed positionally here and through
|
|
1686
1770
|
// applyRenderResult() rather than folded into `j` (which is the
|
|
1687
1771
|
// server's response payload, not render-context the CALLER derived).
|
|
1772
|
+
// v3.13.1 R11: the selection right before the DOM is replaced — a click,
|
|
1773
|
+
// drag or double-click into another block during this request has
|
|
1774
|
+
// already put it there. The focusin re-resolve and the flight replay
|
|
1775
|
+
// restore it, anchor and focus both.
|
|
1776
|
+
lastRenderSel = selectionSnapshotIn(activeFlightSurface());
|
|
1688
1777
|
return applyRenderResult(j, editRange, scrollY, declaredRange);
|
|
1689
1778
|
}
|
|
1690
1779
|
|
|
@@ -1869,6 +1958,9 @@
|
|
|
1869
1958
|
// 要為一次沒上畫面的編輯升起警告」這件事寫在這裡,而不是留給讀者去讀
|
|
1870
1959
|
// 它們的本體。
|
|
1871
1960
|
if (!ok) return false;
|
|
1961
|
+
// v3.14.0 T4: a render that leaves no block at all (the last one deleted)
|
|
1962
|
+
// is an empty document again, with a transient to type into.
|
|
1963
|
+
ensureEmptyDocTransient();
|
|
1872
1964
|
lastRenderLines = lines.slice();
|
|
1873
1965
|
lastRenderEndedUnclosed = nowUnclosed;
|
|
1874
1966
|
// 升 banner 排在套用之後:套用是把新的 DOM 放上畫面那一步,警告的是使用
|
|
@@ -2521,6 +2613,9 @@
|
|
|
2521
2613
|
// insertBlockBelow() rerenderAll() awaits, so it can never clobber a
|
|
2522
2614
|
// fresh assignment.
|
|
2523
2615
|
pristineInsert = null;
|
|
2616
|
+
// v3.14.0 T1: the transient lived in the `.content` children the
|
|
2617
|
+
// innerHTML write above just replaced — every render drops it (D3).
|
|
2618
|
+
transient = null;
|
|
2524
2619
|
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
2525
2620
|
// WYSIWYG burst — whatever block had one open a moment ago was just
|
|
2526
2621
|
// detached by the innerHTML replacement above, so its own focusout
|
|
@@ -2664,14 +2759,531 @@
|
|
|
2664
2759
|
// refusal, and commitListStructure()'s no-range exit) pass nothing, which
|
|
2665
2760
|
// falls back to a full re-render by contract — patch can only compare
|
|
2666
2761
|
// renderer output, never DOM the user has already touched.
|
|
2762
|
+
//
|
|
2763
|
+
// v3.13.1 T1: every render is also a "commit in flight" for the input guard
|
|
2764
|
+
// below — see `commitFlight`. A render that lands re-points every held
|
|
2765
|
+
// entry's tag at the surface that now holds the same source.
|
|
2667
2766
|
async function safeRerenderAll(editRange) {
|
|
2767
|
+
const blocksBefore = blocks;
|
|
2768
|
+
const lineMap = pendingFlightLineMap;
|
|
2769
|
+
pendingFlightLineMap = null;
|
|
2770
|
+
beginCommitFlight();
|
|
2771
|
+
let ok = false;
|
|
2668
2772
|
try {
|
|
2669
|
-
|
|
2773
|
+
ok = await rerenderAll(editRange);
|
|
2774
|
+
return ok;
|
|
2670
2775
|
} catch (e) {
|
|
2671
2776
|
showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
|
|
2672
2777
|
')。文件維持修改前的樣子。', null, null);
|
|
2673
2778
|
return false;
|
|
2779
|
+
} finally {
|
|
2780
|
+
if (ok) reanchorFlightBuffer(blocksBefore, editRange, lineMap);
|
|
2781
|
+
// A failed render: nothing places focus afterwards (commitListStructure()
|
|
2782
|
+
// skips its focusBlockAtLine(), and a failed blur-commit refocuses the
|
|
2783
|
+
// block it could not commit), so the opening run replays where it was
|
|
2784
|
+
// typed. Measured before round 3: edit A, click B@5, `ZZ`, render 500 —
|
|
2785
|
+
// `ZZ` went to the start of A.
|
|
2786
|
+
else pinFirstFlightRun(null);
|
|
2787
|
+
endCommitFlight();
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// ── v3.13.1 T1: the commit-in-flight input guard ──────────────────────
|
|
2792
|
+
// A commit awaits /api/render and then rebuilds the surfaces it covers from
|
|
2793
|
+
// `lines`. Anything typed into a surface while that request is out lands in
|
|
2794
|
+
// DOM the render is about to throw away. Measured before this (audit
|
|
2795
|
+
// 2026-10-10): Enter then an immediate `type('new')` in a tight list lost
|
|
2796
|
+
// every character in Chromium and wrote an extra empty `-` item; with the
|
|
2797
|
+
// render held 800 ms, 100% loss in both engines.
|
|
2798
|
+
//
|
|
2799
|
+
// While a flight is active (flightActive(): a commit in flight, or held
|
|
2800
|
+
// entries still waiting to replay), what is typed into an armed surface is held in
|
|
2801
|
+
// `flightBuffer` instead of reaching the DOM — text, paste, and the keys
|
|
2802
|
+
// that edit or move: Enter, Tab, Backspace/Delete (and their word/line
|
|
2803
|
+
// chords), arrows, Home/End. Escape drops everything held (R12: Escape
|
|
2804
|
+
// means "throw away what I have not committed", in flight or not). Once
|
|
2805
|
+
// the render has landed and its caller has placed focus, the entries are
|
|
2806
|
+
// replayed in order, each through the path the live input would have taken
|
|
2807
|
+
// (ruling R13): text through the browser's own insertText (which replaces a
|
|
2808
|
+
// selection and rebalances whitespace; insertTextAtCaret() is the fallback),
|
|
2809
|
+
// paste through routePaste(), keys as a keydown through the
|
|
2810
|
+
// document handler — so ↑/↓ can still cross blocks and a table cell still
|
|
2811
|
+
// owns its keys — and, when no handler claims the key, its native default
|
|
2812
|
+
// (execCommand deletion, Selection.modify movement).
|
|
2813
|
+
//
|
|
2814
|
+
// WHERE an entry replays (fix rounds 1-3):
|
|
2815
|
+
// * A "continuation" entry (pos === null) replays at the selection the
|
|
2816
|
+
// replay has reached — wherever focus is once the caller placed it, or
|
|
2817
|
+
// wherever an earlier replayed key moved it (a held ArrowDown that walks
|
|
2818
|
+
// to the next block takes the keys typed after it there). A held key
|
|
2819
|
+
// never moves the DOM selection; a pointer press does, so the press
|
|
2820
|
+
// first PINS the run of continuation entries that opened the flight to
|
|
2821
|
+
// the selection it is about to move away from (pinFirstFlightRun(); a
|
|
2822
|
+
// failed render pins it too, since then no caller places focus). Every
|
|
2823
|
+
// other run already starts at a positioned entry. Measured before round
|
|
2824
|
+
// 3: click B@5, `xy`, click Omega, `Z` replayed `xy` at B's start.
|
|
2825
|
+
// * A "positioned" entry is the first one held after a pointer press into
|
|
2826
|
+
// a surface during the flight: it carries a tag (surface, block id,
|
|
2827
|
+
// table cell index) and the full selection (anchor and focus offsets),
|
|
2828
|
+
// restored before it replays. Its offsets are the ones the user saw,
|
|
2829
|
+
// in the surface's text at hold time, and the tag records how many
|
|
2830
|
+
// replayed edits that surface had logged by then (`logAt`). Just before
|
|
2831
|
+
// it replays, the offsets are mapped through the edits logged in that
|
|
2832
|
+
// surface AFTER that point — the ones the user's text did not hold yet
|
|
2833
|
+
// — as measured (flightEditLog, mapOffsetThroughLog(); v3.14.0 T7, D10,
|
|
2834
|
+
// fix round 1). When a render replaces the surface, the offsets are
|
|
2835
|
+
// mapped through the old surface's later edits right there and the tag
|
|
2836
|
+
// starts over against the new one's log. The tag is re-pointed when a render
|
|
2837
|
+
// replaces its surface: by the committed run's own line map for an item
|
|
2838
|
+
// of a list run the commit rewrote (an item the split pushed down a
|
|
2839
|
+
// line, the provisional item), otherwise by its block's start line,
|
|
2840
|
+
// shifted by the commit's line delta when it lay below the edited range.
|
|
2841
|
+
// Measured before round 2: Enter, `ab`, click `charlie`, `Z` saved
|
|
2842
|
+
// `- abZ` with charlie untouched.
|
|
2843
|
+
// * A pointer press during the flight with nothing typed after it becomes
|
|
2844
|
+
// a { kind: 'focus' } entry, so the click is not lost to the commit's
|
|
2845
|
+
// own focusBlockAtLine().
|
|
2846
|
+
//
|
|
2847
|
+
// A failed render keeps the buffer too: rerenderAll() leaves `.content`
|
|
2848
|
+
// untouched on failure, every surface is still connected, and the replay
|
|
2849
|
+
// lands where it was typed.
|
|
2850
|
+
let commitFlight = 0;
|
|
2851
|
+
const flightBuffer = [];
|
|
2852
|
+
// A pointer press into a surface during a flight: the next held entry is
|
|
2853
|
+
// positioned (see above).
|
|
2854
|
+
let flightSelMoved = false;
|
|
2855
|
+
// Set while an entry replays: the replay's own keydown / insertText must
|
|
2856
|
+
// not be held again.
|
|
2857
|
+
let flightReplaying = false;
|
|
2858
|
+
function flightActive() {
|
|
2859
|
+
return !flightReplaying && (commitFlight > 0 || flightBuffer.length > 0);
|
|
2860
|
+
}
|
|
2861
|
+
// The selection right before the latest render replaced the DOM (R11).
|
|
2862
|
+
let lastRenderSel = null;
|
|
2863
|
+
// Where continuation entries go when the render took focus away and no
|
|
2864
|
+
// caller put it anywhere (e.g. a Ctrl+S commit of the focused block).
|
|
2865
|
+
let flightFallback = null;
|
|
2866
|
+
// commitListStructure() -> safeRerenderAll(): the run's line map.
|
|
2867
|
+
let pendingFlightLineMap = null;
|
|
2868
|
+
const FLIGHT_DROPPED_MESSAGE = '重新整理期間打的字沒有地方放,已略過';
|
|
2869
|
+
|
|
2870
|
+
function beginCommitFlight() {
|
|
2871
|
+
commitFlight++;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// Not a synchronous decrement. The caller's continuation after
|
|
2875
|
+
// `await safeRerenderAll(...)` — focusBlockAtLine(), an arrow walk's
|
|
2876
|
+
// landing — is a microtask, so it runs before this timer and focus is
|
|
2877
|
+
// placed before the replay. Keys that arrive between the render landing
|
|
2878
|
+
// and the timer are still held, behind the earlier ones.
|
|
2879
|
+
function endCommitFlight() {
|
|
2880
|
+
setTimeout(() => {
|
|
2881
|
+
commitFlight--;
|
|
2882
|
+
if (!commitFlight) flushFlightBuffer();
|
|
2883
|
+
}, 0);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// A replay that had to wait for a focus change resumes from here (the
|
|
2887
|
+
// focusin handlers call it once their burst is open).
|
|
2888
|
+
function scheduleFlightFlush() {
|
|
2889
|
+
if (flightBuffer.length && !commitFlight) setTimeout(flushFlightBuffer, 0);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
function flightSurfaceOf(node) {
|
|
2893
|
+
const el = node && (node.nodeType === 1 ? node : node.parentElement);
|
|
2894
|
+
return el && el.closest ? el.closest('.ed-wys-armed, .ed-wys-cell') : null;
|
|
2895
|
+
}
|
|
2896
|
+
function activeFlightSurface() {
|
|
2897
|
+
return flightSurfaceOf(document.activeElement);
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// Selection points as character offsets inside a surface, counted over
|
|
2901
|
+
// text nodes only — the same count pointInSurface() walks back.
|
|
2902
|
+
function offsetInSurface(surface, node, offset) {
|
|
2903
|
+
const r = document.createRange();
|
|
2904
|
+
r.selectNodeContents(surface);
|
|
2905
|
+
r.setEnd(node, offset);
|
|
2906
|
+
return r.toString().length;
|
|
2907
|
+
}
|
|
2908
|
+
function pointInSurface(surface, offset) {
|
|
2909
|
+
let left = offset;
|
|
2910
|
+
let lastText = null;
|
|
2911
|
+
const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
|
|
2912
|
+
for (let t = walker.nextNode(); t; t = walker.nextNode()) {
|
|
2913
|
+
if (left <= t.data.length) return [t, left];
|
|
2914
|
+
left -= t.data.length;
|
|
2915
|
+
lastText = t;
|
|
2916
|
+
}
|
|
2917
|
+
return lastText ? [lastText, lastText.data.length] : [surface, 0];
|
|
2918
|
+
}
|
|
2919
|
+
// { surface, anchor, focus } for the selection in `surface`; offsets are
|
|
2920
|
+
// null when the selection is not inside it.
|
|
2921
|
+
function selectionSnapshotIn(surface) {
|
|
2922
|
+
if (!surface) return null;
|
|
2923
|
+
const sel = window.getSelection();
|
|
2924
|
+
if (!sel.rangeCount || !surface.contains(sel.anchorNode) || !surface.contains(sel.focusNode)) {
|
|
2925
|
+
return { surface, anchor: null, focus: null };
|
|
2926
|
+
}
|
|
2927
|
+
return { surface,
|
|
2928
|
+
anchor: offsetInSurface(surface, sel.anchorNode, sel.anchorOffset),
|
|
2929
|
+
focus: offsetInSurface(surface, sel.focusNode, sel.focusOffset) };
|
|
2930
|
+
}
|
|
2931
|
+
function restoreSelection(surface, anchor, focus) {
|
|
2932
|
+
if (anchor === null || anchor === undefined) return;
|
|
2933
|
+
const a = pointInSurface(surface, anchor);
|
|
2934
|
+
const f = pointInSurface(surface, focus === null || focus === undefined ? anchor : focus);
|
|
2935
|
+
window.getSelection().setBaseAndExtent(a[0], a[1], f[0], f[1]);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function flightTagOf(snap) {
|
|
2939
|
+
if (!snap || !snap.surface) return null;
|
|
2940
|
+
const blockEl = snap.surface.closest('.ed-block');
|
|
2941
|
+
const isCell = snap.surface.classList.contains('ed-wys-cell');
|
|
2942
|
+
return {
|
|
2943
|
+
surface: snap.surface,
|
|
2944
|
+
blockId: blockEl ? blockEl.getAttribute('data-block-id') : null,
|
|
2945
|
+
cellIndex: isCell && blockEl
|
|
2946
|
+
? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
|
|
2947
|
+
anchor: snap.anchor, focus: snap.focus,
|
|
2948
|
+
logAt: flightLogLength(snap.surface),
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// The committed run's lines after the commit: the serializer's own
|
|
2953
|
+
// line -> block map (`lineMeta`, one entry per emitted line), keyed by the
|
|
2954
|
+
// block ids the run had before it. The provisional item has no id.
|
|
2955
|
+
function flightLineMapOf(lineMeta, startLine) {
|
|
2956
|
+
const map = { ids: {}, provisional: null };
|
|
2957
|
+
(lineMeta || []).forEach((m, k) => {
|
|
2958
|
+
if (m.blank) return;
|
|
2959
|
+
if (m.blockId === null || m.blockId === undefined) {
|
|
2960
|
+
if (map.provisional === null) map.provisional = startLine + k;
|
|
2961
|
+
} else if (!(m.blockId in map.ids)) {
|
|
2962
|
+
map.ids[m.blockId] = startLine + k;
|
|
2963
|
+
}
|
|
2964
|
+
});
|
|
2965
|
+
return map;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// v3.14.0 T7 (D10): what each replayed entry did to its surface, measured
|
|
2969
|
+
// rather than predicted: surface -> [{ at, delta }] in replay order, cleared
|
|
2970
|
+
// when the buffer empties. A positioned entry's offsets were taken in the
|
|
2971
|
+
// DOM the user clicked, which none of the held edits had reached yet; each
|
|
2972
|
+
// logged edit moves an offset at or past its `at` by its `delta`, never
|
|
2973
|
+
// back over `at`. Predicting the shift at hold time counted only
|
|
2974
|
+
// held TEXT: measured before, a Backspace,
|
|
2975
|
+
// a paste or a replaced word ahead of a second click in the same paragraph
|
|
2976
|
+
// put that click's keys one character, two, or eight away from it, and two
|
|
2977
|
+
// clicks at one spot replayed their keys in reverse order.
|
|
2978
|
+
const flightEditLog = new Map();
|
|
2979
|
+
function surfaceTextLength(surface) {
|
|
2980
|
+
const r = document.createRange();
|
|
2981
|
+
r.selectNodeContents(surface);
|
|
2982
|
+
return r.toString().length;
|
|
2983
|
+
}
|
|
2984
|
+
function flightLogLength(surface) {
|
|
2985
|
+
const log = surface ? flightEditLog.get(surface) : null;
|
|
2986
|
+
return log ? log.length : 0;
|
|
2987
|
+
}
|
|
2988
|
+
// `tag`'s offset `o` through the edits its surface logged after the tag
|
|
2989
|
+
// was taken (fix round 1, I1: mapping the whole log shifted a tag taken in
|
|
2990
|
+
// a surface that already held an earlier replayed edit a second time).
|
|
2991
|
+
function mapOffsetThroughLog(tag, o) {
|
|
2992
|
+
if (o === null || o === undefined || !tag || !tag.surface) return o;
|
|
2993
|
+
const log = flightEditLog.get(tag.surface) || [];
|
|
2994
|
+
for (let i = tag.logAt || 0; i < log.length; i++) {
|
|
2995
|
+
const m = log[i];
|
|
2996
|
+
o = o < m.at ? o : Math.max(m.at, o + m.delta);
|
|
2997
|
+
}
|
|
2998
|
+
return o;
|
|
2999
|
+
}
|
|
3000
|
+
// Ctrl/Cmd+B / I / U (no Alt, no Shift): the native formatting command.
|
|
3001
|
+
function formatCommandForKey(e) {
|
|
3002
|
+
const mod = FLIGHT_IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
|
|
3003
|
+
if (!mod || e.altKey || e.shiftKey || typeof e.key !== 'string') return null;
|
|
3004
|
+
const k = e.key.toLowerCase();
|
|
3005
|
+
return k === 'b' ? 'bold' : k === 'i' ? 'italic' : k === 'u' ? 'underline' : null;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// Pins the run of continuation entries that opened this flight (if it is
|
|
3009
|
+
// still unpinned) to `snap`, or to its own first entry's hint — where it
|
|
3010
|
+
// was typed — when there is no usable selection.
|
|
3011
|
+
function pinFirstFlightRun(snap) {
|
|
3012
|
+
const first = flightBuffer[0];
|
|
3013
|
+
if (!first || first.pos) return;
|
|
3014
|
+
const tag = snap && snap.anchor !== null ? flightTagOf(snap) : null;
|
|
3015
|
+
first.pos = tag || (first.hint ? Object.assign({}, first.hint) : null);
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function holdFlightEntry(surface, entry) {
|
|
3019
|
+
entry.pos = null;
|
|
3020
|
+
entry.hint = flightTagOf(selectionSnapshotIn(surface));
|
|
3021
|
+
if (flightSelMoved) {
|
|
3022
|
+
flightSelMoved = false;
|
|
3023
|
+
entry.pos = flightTagOf(selectionSnapshotIn(surface));
|
|
3024
|
+
}
|
|
3025
|
+
const last = flightBuffer[flightBuffer.length - 1];
|
|
3026
|
+
if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
|
|
3027
|
+
last.data += entry.data;
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
flightBuffer.push(entry);
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// After a render lands: re-point every tag whose surface it replaced.
|
|
3034
|
+
function reanchorFlightBuffer(before, editRange, lineMap) {
|
|
3035
|
+
if (flightSelMoved && lastRenderSel) {
|
|
3036
|
+
flightSelMoved = false;
|
|
3037
|
+
flightBuffer.push({ kind: 'focus', pos: flightTagOf(lastRenderSel) });
|
|
3038
|
+
}
|
|
3039
|
+
flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
|
|
3040
|
+
? flightTagOf(lastRenderSel) : null;
|
|
3041
|
+
const remap = (pos) => {
|
|
3042
|
+
if (!pos) return;
|
|
3043
|
+
if (pos.surface && pos.surface.isConnected) {
|
|
3044
|
+
const own = pos.surface.closest('.ed-block');
|
|
3045
|
+
pos.blockId = own ? own.getAttribute('data-block-id') : pos.blockId;
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
let line = null;
|
|
3049
|
+
if (lineMap) {
|
|
3050
|
+
if (pos.blockId === null) line = lineMap.provisional;
|
|
3051
|
+
else if (pos.blockId in lineMap.ids) line = lineMap.ids[pos.blockId];
|
|
3052
|
+
}
|
|
3053
|
+
if (line === null && pos.blockId !== null) {
|
|
3054
|
+
const rec = (before || []).find((b) => b.id === Number(pos.blockId));
|
|
3055
|
+
if (rec) {
|
|
3056
|
+
line = rec.startLine;
|
|
3057
|
+
if (editRange && line > editRange.endLine) line += editRange.delta;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
// Fix round 1 (I1): on a commit render, the render replaced the surface
|
|
3061
|
+
// with one built from the source that commit wrote, which holds every
|
|
3062
|
+
// edit the replay made in the old one before the commit. So the offsets
|
|
3063
|
+
// are mapped through the old surface's later edits now, and the tag
|
|
3064
|
+
// starts over against the new surface's log. This holds for a commit
|
|
3065
|
+
// render only (final review M2): a rollback render (the
|
|
3066
|
+
// PENDING_STRUCTURE_LOST branch) or a global undo() render builds the
|
|
3067
|
+
// surface from a source without those edits, and a held click's offset
|
|
3068
|
+
// can then be off by their length. Accepted for v3.14.0: neither is
|
|
3069
|
+
// reached by a known gesture short of four inside one render. Measured before: click B@5, X, click Omega, Y, click
|
|
3070
|
+
// B@10, Z — Z landed one character short, `BravoX parZagraph`.
|
|
3071
|
+
if (pos.surface) {
|
|
3072
|
+
pos.anchor = mapOffsetThroughLog(pos, pos.anchor);
|
|
3073
|
+
pos.focus = mapOffsetThroughLog(pos, pos.focus);
|
|
3074
|
+
}
|
|
3075
|
+
pos.surface = null;
|
|
3076
|
+
pos.logAt = 0;
|
|
3077
|
+
if (line === null) return;
|
|
3078
|
+
const nb = blocks.find((b) => b.startLine === line);
|
|
3079
|
+
const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
|
|
3080
|
+
if (!nEl) return;
|
|
3081
|
+
let surf = pos.cellIndex !== null && pos.cellIndex !== undefined
|
|
3082
|
+
? nEl.querySelectorAll('.ed-wys-cell')[pos.cellIndex] || null
|
|
3083
|
+
: blockContentEl(nEl);
|
|
3084
|
+
if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
|
|
3085
|
+
pos.surface = surf;
|
|
3086
|
+
pos.logAt = flightLogLength(surf);
|
|
3087
|
+
pos.blockId = String(nb.id);
|
|
3088
|
+
};
|
|
3089
|
+
flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
|
|
3090
|
+
remap(flightFallback);
|
|
3091
|
+
focusinWaitTags.forEach(remap);
|
|
3092
|
+
}
|
|
3093
|
+
// v3.14.0 T1 fix round 1 (I1): the surfaces a focusin is waiting to open a
|
|
3094
|
+
// burst on while a resolution renders (see the focusin handler). Each is a
|
|
3095
|
+
// flight tag, re-pointed by every landed render exactly like a held entry's,
|
|
3096
|
+
// because the block's id is not a stable way back once a render changed the
|
|
3097
|
+
// block count above it.
|
|
3098
|
+
const focusinWaitTags = [];
|
|
3099
|
+
|
|
3100
|
+
// A deletion chord's input type, and a caret chord's movement, per
|
|
3101
|
+
// platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
|
|
3102
|
+
const FLIGHT_IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
|
3103
|
+
function deletionForKey(e) {
|
|
3104
|
+
const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
|
|
3105
|
+
const line = FLIGHT_IS_MAC && e.metaKey && !e.altKey && !e.ctrlKey;
|
|
3106
|
+
const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
|
|
3107
|
+
if (e.key === 'Backspace') {
|
|
3108
|
+
return plain ? 'deleteContentBackward' : word ? 'deleteWordBackward' : line ? 'deleteSoftLineBackward' : null;
|
|
3109
|
+
}
|
|
3110
|
+
if (e.key === 'Delete') {
|
|
3111
|
+
return plain ? 'deleteContentForward' : word ? 'deleteWordForward' : line ? 'deleteSoftLineForward' : null;
|
|
2674
3112
|
}
|
|
3113
|
+
return null;
|
|
3114
|
+
}
|
|
3115
|
+
function caretMoveForKey(e) {
|
|
3116
|
+
const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
|
|
3117
|
+
const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
|
|
3118
|
+
const extend = !!e.shiftKey;
|
|
3119
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
3120
|
+
if (!plain && !word) return null;
|
|
3121
|
+
return { dir: e.key === 'ArrowLeft' ? 'backward' : 'forward', unit: word ? 'word' : 'character', extend };
|
|
3122
|
+
}
|
|
3123
|
+
if (!plain) return null;
|
|
3124
|
+
if (e.key === 'Home' || e.key === 'End') return { dir: e.key === 'Home' ? 'backward' : 'forward', unit: 'lineboundary', extend };
|
|
3125
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return { dir: e.key === 'ArrowUp' ? 'backward' : 'forward', unit: 'line', extend };
|
|
3126
|
+
return null;
|
|
3127
|
+
}
|
|
3128
|
+
function isHeldTyping(en) {
|
|
3129
|
+
return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' || en.kind === 'format' ||
|
|
3130
|
+
(en.kind === 'key' && !caretMoveForKey(en));
|
|
3131
|
+
}
|
|
3132
|
+
function dropHeldTyping() {
|
|
3133
|
+
const kept = [];
|
|
3134
|
+
let carry = null;
|
|
3135
|
+
flightBuffer.forEach((en) => {
|
|
3136
|
+
if (isHeldTyping(en)) {
|
|
3137
|
+
if (en.pos) carry = en.pos;
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
if (en.pos) carry = null;
|
|
3141
|
+
else if (carry) { en.pos = carry; carry = null; }
|
|
3142
|
+
kept.push(en);
|
|
3143
|
+
});
|
|
3144
|
+
flightBuffer.length = 0;
|
|
3145
|
+
kept.forEach((en) => flightBuffer.push(en));
|
|
3146
|
+
}
|
|
3147
|
+
function flightHoldsKey(e) {
|
|
3148
|
+
if ((e.key === 'Enter' || e.key === 'Tab') && !e.ctrlKey && !e.metaKey && !e.altKey) return true;
|
|
3149
|
+
return !!(deletionForKey(e) || caretMoveForKey(e));
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
// The native default of a deletion, against the restored selection: a
|
|
3153
|
+
// selection is deleted whole; a collapsed caret deletes one character, or
|
|
3154
|
+
// is first extended by a word / to the line boundary.
|
|
3155
|
+
function nativeFlightDeletion(t, target) {
|
|
3156
|
+
const sel = window.getSelection();
|
|
3157
|
+
if (!sel.rangeCount) return;
|
|
3158
|
+
const back = /Backward$/.test(t);
|
|
3159
|
+
const unit = /Word/.test(t) ? 'word' : (/Line/.test(t) ? 'lineboundary' : null);
|
|
3160
|
+
if (sel.isCollapsed && unit) {
|
|
3161
|
+
if (!sel.modify) return;
|
|
3162
|
+
sel.modify('extend', back ? 'backward' : 'forward', unit);
|
|
3163
|
+
// Nothing to delete in that direction: a collapsed 'delete' would take
|
|
3164
|
+
// a character instead.
|
|
3165
|
+
if (sel.isCollapsed) return;
|
|
3166
|
+
}
|
|
3167
|
+
snapBurstIfActive(target, 'replay-pre');
|
|
3168
|
+
document.execCommand(sel.isCollapsed && !back ? 'forwardDelete' : 'delete');
|
|
3169
|
+
snapBurstIfActive(target, 'replay');
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function replayFlightEntry(item, target) {
|
|
3173
|
+
if (item.kind === 'text') {
|
|
3174
|
+
// The browser's own insertText, not insertTextAtCaret(): it replaces a
|
|
3175
|
+
// selection the same way, and it also rebalances the whitespace around
|
|
3176
|
+
// the caret the way live typing does (measured: a held Ctrl+Backspace
|
|
3177
|
+
// leaves `ab` + NBSP, and only the native insertion of the next `e`
|
|
3178
|
+
// turns that back into `ab e`).
|
|
3179
|
+
snapBurstIfActive(target, 'replay-pre');
|
|
3180
|
+
if (/[\r\n]/.test(item.data) || !document.execCommand('insertText', false, item.data)) {
|
|
3181
|
+
insertTextAtCaret(item.data);
|
|
3182
|
+
}
|
|
3183
|
+
snapBurstIfActive(target, 'replay');
|
|
3184
|
+
} else if (item.kind === 'paste') {
|
|
3185
|
+
plainPasteArmed = item.plainOnly;
|
|
3186
|
+
routePaste(target, item.dt);
|
|
3187
|
+
} else if (item.kind === 'delete') {
|
|
3188
|
+
nativeFlightDeletion(item.inputType, target);
|
|
3189
|
+
} else if (item.kind === 'format') {
|
|
3190
|
+
// v3.14.0 T7 (D9): the command the live chord runs natively.
|
|
3191
|
+
snapBurstIfActive(target, 'replay-pre');
|
|
3192
|
+
document.execCommand(item.command);
|
|
3193
|
+
snapBurstIfActive(target, 'replay');
|
|
3194
|
+
} else if (item.kind === 'key') {
|
|
3195
|
+
const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
|
|
3196
|
+
ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
|
|
3197
|
+
target.dispatchEvent(ev);
|
|
3198
|
+
if (ev.defaultPrevented) return;
|
|
3199
|
+
const del = deletionForKey(item);
|
|
3200
|
+
if (del) { nativeFlightDeletion(del, target); return; }
|
|
3201
|
+
const move = caretMoveForKey(item);
|
|
3202
|
+
const sel = window.getSelection();
|
|
3203
|
+
if (move && sel.modify) sel.modify(move.extend ? 'extend' : 'move', move.dir, move.unit);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function flushFlightBuffer() {
|
|
3208
|
+
if (commitFlight || switching) return;
|
|
3209
|
+
let dropped = false;
|
|
3210
|
+
// Replay in order. A replayed key, or the focus change to a positioned
|
|
3211
|
+
// entry's surface, can start a new commit (or wait on one); the rest
|
|
3212
|
+
// waits and resumes from its endCommitFlight() or the focusin handlers.
|
|
3213
|
+
while (flightBuffer.length && !commitFlight && !switching) {
|
|
3214
|
+
const item = flightBuffer[0];
|
|
3215
|
+
let target = null;
|
|
3216
|
+
if (item.pos) {
|
|
3217
|
+
target = item.pos.surface && item.pos.surface.isConnected ? item.pos.surface : null;
|
|
3218
|
+
if (!target) { flightBuffer.shift(); dropped = true; continue; }
|
|
3219
|
+
if (activeFlightSurface() !== target) {
|
|
3220
|
+
target.focus();
|
|
3221
|
+
if (commitFlight || switching) break;
|
|
3222
|
+
}
|
|
3223
|
+
restoreSelection(target, mapOffsetThroughLog(item.pos, item.pos.anchor),
|
|
3224
|
+
mapOffsetThroughLog(item.pos, item.pos.focus));
|
|
3225
|
+
} else {
|
|
3226
|
+
target = activeFlightSurface();
|
|
3227
|
+
const fb = flightFallback;
|
|
3228
|
+
if (!target && fb && fb.surface && fb.surface.isConnected) {
|
|
3229
|
+
fb.surface.focus();
|
|
3230
|
+
if (commitFlight || switching) break;
|
|
3231
|
+
restoreSelection(fb.surface, mapOffsetThroughLog(fb, fb.anchor),
|
|
3232
|
+
mapOffsetThroughLog(fb, fb.focus));
|
|
3233
|
+
flightFallback = null;
|
|
3234
|
+
target = activeFlightSurface();
|
|
3235
|
+
}
|
|
3236
|
+
if (!target) { flightBuffer.shift(); dropped = true; continue; }
|
|
3237
|
+
}
|
|
3238
|
+
flightBuffer.shift();
|
|
3239
|
+
if (item.kind !== 'focus') {
|
|
3240
|
+
const pre = selectionSnapshotIn(target);
|
|
3241
|
+
const len0 = surfaceTextLength(target);
|
|
3242
|
+
flightReplaying = true;
|
|
3243
|
+
try { replayFlightEntry(item, target); } finally { flightReplaying = false; }
|
|
3244
|
+
// T7: measured only where it can be — the surface still on screen
|
|
3245
|
+
// and still focused (an Enter that moved the caret on, a commit that
|
|
3246
|
+
// replaced the surface, is no edit IN it to map through).
|
|
3247
|
+
if (pre && pre.anchor !== null && target.isConnected && activeFlightSurface() === target) {
|
|
3248
|
+
const post = selectionSnapshotIn(target);
|
|
3249
|
+
if (post && post.focus !== null) {
|
|
3250
|
+
const delta = surfaceTextLength(target) - len0;
|
|
3251
|
+
const s0 = Math.min(pre.anchor, pre.focus === null ? pre.anchor : pre.focus);
|
|
3252
|
+
const at = Math.min(s0, post.focus - Math.max(delta, 0));
|
|
3253
|
+
if (!flightEditLog.has(target)) flightEditLog.set(target, []);
|
|
3254
|
+
flightEditLog.get(target).push({ at, delta });
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
if (!flightBuffer.length) flightEditLog.clear();
|
|
3260
|
+
if (!flightBuffer.length && !commitFlight) flightFallback = null;
|
|
3261
|
+
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// An IME composition cannot be cancelled (its beforeinput is not
|
|
3265
|
+
// cancelable), so the committed text is already in the DOM when
|
|
3266
|
+
// compositionend reports it. It is taken back out when it sits right
|
|
3267
|
+
// before the caret, which leaves the buffer its only owner — a render that
|
|
3268
|
+
// keeps this surface (a failed one, or a patch that does not cover it)
|
|
3269
|
+
// would otherwise show it twice once the replay lands. Returns false when
|
|
3270
|
+
// it cannot be found there; the caller holds it anyway, and the render
|
|
3271
|
+
// that replaces the surface is what removes the DOM copy.
|
|
3272
|
+
function takeBackComposedText(data) {
|
|
3273
|
+
const sel = window.getSelection();
|
|
3274
|
+
if (!data || !sel.rangeCount) return false;
|
|
3275
|
+
const r = sel.getRangeAt(0);
|
|
3276
|
+
if (!r.collapsed || r.startContainer.nodeType !== 3) return false;
|
|
3277
|
+
const node = r.startContainer;
|
|
3278
|
+
const start = r.startOffset - data.length;
|
|
3279
|
+
if (start < 0 || node.data.slice(start, r.startOffset) !== data) return false;
|
|
3280
|
+
node.deleteData(start, data.length);
|
|
3281
|
+
const c = document.createRange();
|
|
3282
|
+
c.setStart(node, start);
|
|
3283
|
+
c.collapse(true);
|
|
3284
|
+
sel.removeAllRanges();
|
|
3285
|
+
sel.addRange(c);
|
|
3286
|
+
return true;
|
|
2675
3287
|
}
|
|
2676
3288
|
|
|
2677
3289
|
function autoSize(ta) {
|
|
@@ -2916,6 +3528,9 @@
|
|
|
2916
3528
|
// v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
|
|
2917
3529
|
// re-derives all three of `blockId`/`startLine`/`endLine` when this
|
|
2918
3530
|
// session survives an incremental patch. See its own comment.
|
|
3531
|
+
// R2 (F1): an id-less block (a transient) has no source to edit, and
|
|
3532
|
+
// `Number(null)` would open block 0's.
|
|
3533
|
+
if (blockEl.getAttribute('data-block-id') === null) return;
|
|
2919
3534
|
let blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
2920
3535
|
const block = blocks.find((b) => b.id === blockId);
|
|
2921
3536
|
if (!block) return;
|
|
@@ -4122,9 +4737,40 @@
|
|
|
4122
4737
|
// `Alpha <em><br></em> bold text here.` — dropping before the insert would
|
|
4123
4738
|
// have taken that <em> and the caret's new home with it.
|
|
4124
4739
|
dropEmptied(leftovers);
|
|
4740
|
+
// v3.14.0 T8: a <br> with nothing visible after it in the surface draws
|
|
4741
|
+
// no new line — the caret after it stays on the line above, and the next
|
|
4742
|
+
// character was typed BEFORE the break (measured: Shift+Enter at the end
|
|
4743
|
+
// of `Alpha.`, then `Z`, saved `Alpha.Z<br>`). A second, tail <br>
|
|
4744
|
+
// gives the new line something to stand on; the caret goes between the
|
|
4745
|
+
// two. inline-md.js writes nothing for a tail, so leaving straight away
|
|
4746
|
+
// writes the same `Alpha.<br>` as before.
|
|
4747
|
+
const surface = editSurfaceOf(br);
|
|
4748
|
+
if (surface) {
|
|
4749
|
+
const tail = tailBrAfter(br, surface);
|
|
4750
|
+
if (tail === false) {
|
|
4751
|
+
const t = document.createElement('br');
|
|
4752
|
+
t.setAttribute('data-ed-tail', '1');
|
|
4753
|
+
br.parentNode.insertBefore(t, br.nextSibling);
|
|
4754
|
+
}
|
|
4755
|
+
range.setStartAfter(br);
|
|
4756
|
+
range.setEndAfter(br);
|
|
4757
|
+
}
|
|
4125
4758
|
sel.removeAllRanges();
|
|
4126
4759
|
sel.addRange(range);
|
|
4127
4760
|
}
|
|
4761
|
+
// What follows `br` inside `surface`: a tail <br> that is already there
|
|
4762
|
+
// (returned), something visible — text, an image, another <br> (true) — or
|
|
4763
|
+
// nothing (false).
|
|
4764
|
+
function tailBrAfter(br, surface) {
|
|
4765
|
+
const w = document.createTreeWalker(surface, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
4766
|
+
w.currentNode = br;
|
|
4767
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
4768
|
+
if (n.nodeType === 3) { if (n.data.length) return true; continue; }
|
|
4769
|
+
if (n.nodeName === 'BR') return n.getAttribute('data-ed-tail') === '1' ? n : true;
|
|
4770
|
+
if (n.nodeName === 'IMG') return true;
|
|
4771
|
+
}
|
|
4772
|
+
return false;
|
|
4773
|
+
}
|
|
4128
4774
|
|
|
4129
4775
|
// Heading ± buttons on the bar: a pure source-level transform (just the
|
|
4130
4776
|
// leading `#` run) via the SAME commitEdit()/replaceLines() pipeline as
|
|
@@ -4179,6 +4825,7 @@
|
|
|
4179
4825
|
|
|
4180
4826
|
async function changeHeadingDepthBody(blockEl, delta, anchor) {
|
|
4181
4827
|
if (!blockEl) return;
|
|
4828
|
+
if (blockEl.getAttribute('data-block-id') === null) return; // R2: id-less → not block 0
|
|
4182
4829
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
4183
4830
|
// Commits (never discards) whatever burst/editor is open first — same
|
|
4184
4831
|
// precondition undo()/redo() use below, so this never races a concurrent
|
|
@@ -4302,6 +4949,9 @@
|
|
|
4302
4949
|
if (blockType === 'heading') stripHeadingAnchor(editEl);
|
|
4303
4950
|
editEl.setAttribute('contenteditable', 'true');
|
|
4304
4951
|
editEl.classList.add('ed-wys-armed');
|
|
4952
|
+
// v3.14.0 T4: the hint a focused EMPTY paragraph shows (the CSS rule
|
|
4953
|
+
// reads this attribute; syncEmptyMark() decides when it shows).
|
|
4954
|
+
if (blockType === 'paragraph') editEl.setAttribute('data-placeholder', PLACEHOLDER_TEXT);
|
|
4305
4955
|
} else if (blockType === 'li') {
|
|
4306
4956
|
// Task 6 (Phase 4): per-li arming. Each
|
|
4307
4957
|
// `.ed-block[data-block-type="li"]` is armed independently: only its
|
|
@@ -4928,19 +5578,11 @@
|
|
|
4928
5578
|
// this uses the bare marker `-` instead — verified to lex as `list` with
|
|
4929
5579
|
// one empty `<li>`.
|
|
4930
5580
|
//
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
//
|
|
4934
|
-
//
|
|
4935
|
-
//
|
|
4936
|
-
// locate the inserted block in the server's recomputed block list.
|
|
4937
|
-
// U+200B (zero-width space) is real, non-whitespace-per-`\s` text that
|
|
4938
|
-
// marked DOES lex as its own paragraph, and renders as `<p></p>` —
|
|
4939
|
-
// visually empty. focusInsertedBlock() below selects that single
|
|
4940
|
-
// character so an immediate keystroke replaces it, matching the "empty
|
|
4941
|
-
// paragraph to type into" intent; if the user commits without typing at
|
|
4942
|
-
// all, the ZWSP is what ends up on disk (a known, documented trade-off —
|
|
4943
|
-
// see the phase report).
|
|
5581
|
+
// There is no 段落 skeleton (v3.14.0 T1, D6): a blank line cannot be a
|
|
5582
|
+
// paragraph in markdown, and the zero-width-space line that stood in for
|
|
5583
|
+
// one up to v3.13 put an invisible character on disk whenever the user
|
|
5584
|
+
// walked away without typing. A new paragraph is now a transient (see
|
|
5585
|
+
// createTransient()), which writes nothing until it has content.
|
|
4944
5586
|
//
|
|
4945
5587
|
// 程式碼: a bare two-line fence pair (```/```, nothing between) is what
|
|
4946
5588
|
// "fence pair with empty body" reads as most literally, and DOES lex as
|
|
@@ -4954,7 +5596,6 @@
|
|
|
4954
5596
|
// browser probe). A three-line fence with one blank line between them
|
|
4955
5597
|
// (still `text: ''` per marked — verified) gives that line to land on.
|
|
4956
5598
|
const BLOCK_SKELETONS = {
|
|
4957
|
-
paragraph: [''],
|
|
4958
5599
|
heading: ['## '],
|
|
4959
5600
|
list: ['-'],
|
|
4960
5601
|
table: ['| A | B |', '|---|---|', '| | |'],
|
|
@@ -4972,8 +5613,8 @@
|
|
|
4972
5613
|
// freshly-inserted, placeholder-only block) so the user's very first
|
|
4973
5614
|
// keystroke replaces the placeholder instead of being inserted next to
|
|
4974
5615
|
// it. A no-op-equivalent (nothing to select) on the genuinely-empty
|
|
4975
|
-
// heading/list/table skeletons
|
|
4976
|
-
// skeleton
|
|
5616
|
+
// heading/list/table skeletons, which are all that reach it since the
|
|
5617
|
+
// paragraph skeleton went away (v3.14.0 T1).
|
|
4977
5618
|
function focusAndSelectAll(el) {
|
|
4978
5619
|
el.focus();
|
|
4979
5620
|
const range = document.createRange();
|
|
@@ -5098,6 +5739,16 @@
|
|
|
5098
5739
|
// parameter and not a second insertion path.
|
|
5099
5740
|
async function insertBlockBelow(blockEl, kind, opts) {
|
|
5100
5741
|
if (!blockEl) return;
|
|
5742
|
+
// v3.14.0 T1 (D6): a 段落 is no longer a ZWSP skeleton written to the
|
|
5743
|
+
// file — it is a transient that writes nothing until it has content.
|
|
5744
|
+
if (kind === 'paragraph' && !(opts && Array.isArray(opts.lines))) {
|
|
5745
|
+
await insertTransientBelow(blockEl);
|
|
5746
|
+
return;
|
|
5747
|
+
}
|
|
5748
|
+
// R2 (F1): an id-less anchor (a transient, a provisional li) has no line
|
|
5749
|
+
// to insert below, and `Number(null)` would read it as block 0. The
|
|
5750
|
+
// toolbar's and paste's transient routes are their own (Task 4 / Task 5).
|
|
5751
|
+
if (blockEl.getAttribute('data-block-id') === null) return;
|
|
5101
5752
|
// T7: captured BEFORE switchAwayFrom(), because that is what can renumber
|
|
5102
5753
|
// the ids — see captureBlockIdentity()'s comment.
|
|
5103
5754
|
const identity = captureBlockIdentity(blockEl);
|
|
@@ -5149,7 +5800,8 @@
|
|
|
5149
5800
|
// 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
|
|
5150
5801
|
// non-list kind safe here. Measured on ['# Doc','','- alpha',
|
|
5151
5802
|
// ' - child',' - grand','']: anchored on `child`,
|
|
5152
|
-
// commitBlockInsertion() with the 段落 skeleton
|
|
5803
|
+
// commitBlockInsertion() with the 段落 skeleton of the time (a ZWSP
|
|
5804
|
+
// line; since v3.14.0 a transient commits through the same anchor) yields
|
|
5153
5805
|
// '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
|
|
5154
5806
|
// marked lexes ' - grand' after a paragraph as an INDENTED CODE
|
|
5155
5807
|
// BLOCK — the grandchild's content is gone. Anchored on the end of the
|
|
@@ -5235,7 +5887,7 @@
|
|
|
5235
5887
|
// pasted paragraph it would silently delete real content the moment the
|
|
5236
5888
|
// user clicked elsewhere without typing. focusAndSelectAll() is wrong for
|
|
5237
5889
|
// the same reason: it selects the block's whole content so the first
|
|
5238
|
-
// keystroke replaces it, which is what a
|
|
5890
|
+
// keystroke replaces it, which is what a placeholder skeleton wants and what
|
|
5239
5891
|
// a just-pasted paragraph very much does not.
|
|
5240
5892
|
//
|
|
5241
5893
|
// 'line' is excluded for the same reason from the other direction: both
|
|
@@ -5289,6 +5941,1396 @@
|
|
|
5289
5941
|
}
|
|
5290
5942
|
|
|
5291
5943
|
|
|
5944
|
+
// ── v3.14.0 T1: the transient empty paragraph ─────────────────────────
|
|
5945
|
+
//
|
|
5946
|
+
// Markup (D1): <div class="ed-block" data-block-type="paragraph"
|
|
5947
|
+
// data-ed-transient="1"><p class="ed-wys-armed" contenteditable="true"
|
|
5948
|
+
// data-placeholder="…"></p></div> — no data-block-id, no gutter chrome. It
|
|
5949
|
+
// is placed by DOM position: its commit lands after the previous real
|
|
5950
|
+
// block (the end of that block's subtree for a li), or at the top of the
|
|
5951
|
+
// document when nothing precedes it (D2, transientAnchorEl()). It writes no
|
|
5952
|
+
// byte until it has content (spec §2); its commit is one op.
|
|
5953
|
+
//
|
|
5954
|
+
// WALKER AUDIT (R2 / F1). Every `.ed-block` walker and every
|
|
5955
|
+
// `data-block-id` read in this file, against an id-less transient. "skip" =
|
|
5956
|
+
// returns null / does nothing for it; "include" = treats it as the
|
|
5957
|
+
// paragraph it is; "n/a" = cannot meet one (no transient survives a render,
|
|
5958
|
+
// and a patch render never runs while one exists: domMatchesLastRender()
|
|
5959
|
+
// is false for an id-less child).
|
|
5960
|
+
// applyPatch focusedBlockEl / idDelta rewrite / burstSurvived ... n/a (patch)
|
|
5961
|
+
// flightTagOf / reanchorFlightBuffer remap ............ include: blockId
|
|
5962
|
+
// null resolves through pendingFlightLineMap.provisional, which
|
|
5963
|
+
// commitLinesAtTransient() sets to the landing line
|
|
5964
|
+
// openRawEditor (+ reanchor) ............................ skip (id guard)
|
|
5965
|
+
// blockOwnsNoLine / blockRangeIsUnlocatable ............. skip (null-safe)
|
|
5966
|
+
// allBlockEls / liAttrs / subtreeBlocksAfter / runs ..... include as a
|
|
5967
|
+
// non-li: it ends a list run, as the paragraph it will become does
|
|
5968
|
+
// closestLiBlock ........................................ n/a (not a li)
|
|
5969
|
+
// runRangeOfBlocks / bystanderCarryOver / list commits .. skip (null-safe)
|
|
5970
|
+
// changeHeadingDepthBody ................................ skip (id guard)
|
|
5971
|
+
// armEditables .......................................... n/a (fresh DOM)
|
|
5972
|
+
// focusInsertedBlock / blockElAtLine / focusBlockAtLine . n/a (by line)
|
|
5973
|
+
// insertBlockBelow ...................................... skip (id guard;
|
|
5974
|
+
// its 段落 kind is routed here first)
|
|
5975
|
+
// startBurst ............................................ include (own
|
|
5976
|
+
// branch); any other id-less block: skip (R2)
|
|
5977
|
+
// setBlockSelection / selection walkers / blockRecOf .... skip (null)
|
|
5978
|
+
// captureBlockIdentity / reresolveBlockEl ............... skip (null)
|
|
5979
|
+
// commitListStructure targets ........................... n/a (li only)
|
|
5980
|
+
// handleBurstKeydown arrow probe / walkToAdjacentBlockByArrow /
|
|
5981
|
+
// adjacentFocusableBlock ............................ include: the
|
|
5982
|
+
// arrow walk lands in it like any armed paragraph (T5 owns leaving)
|
|
5983
|
+
// Tab walk (walkToAdjacentBlockByTab) .................. include
|
|
5984
|
+
// blockContentEl ........................................ include (the <p>)
|
|
5985
|
+
// table burst / cell walkers / grips / edge menu ........ n/a (tables)
|
|
5986
|
+
// ⠿ handle / + / drag / duplicate / copy ................ skip: it grows
|
|
5987
|
+
// no .ed-handle or .ed-insert, so no gutter gesture can start on it
|
|
5988
|
+
// gutter hover chrome / delegated block click ........... skip (no chrome;
|
|
5989
|
+
// a click inside it is a caret placement in an armed surface)
|
|
5990
|
+
// toolbar tracking (focusin / click) .................... include: the bar
|
|
5991
|
+
// reports a paragraph (D15 refinements are Task 4)
|
|
5992
|
+
// paste insertBlockBelow / insertImages anchors ......... skip (id guard
|
|
5993
|
+
// in insertBlockBelow; Task 4 / Task 5 route them)
|
|
5994
|
+
// focusin re-resolve by block id ........................ skip (null id)
|
|
5995
|
+
// raw textarea focusout ................................. n/a (no raw box)
|
|
5996
|
+
// wave editor open ...................................... n/a (no diagram)
|
|
5997
|
+
function isTransient(node) {
|
|
5998
|
+
const el = node && (node.nodeType === 1 ? node : node.parentElement);
|
|
5999
|
+
return !!(el && el.closest && el.closest('[data-ed-transient]'));
|
|
6000
|
+
}
|
|
6001
|
+
|
|
6002
|
+
function transientSurfaceEmpty(el) {
|
|
6003
|
+
const surface = el ? blockContentEl(el) : null;
|
|
6004
|
+
return !surface || (surface.textContent === '' && !surface.querySelector('img'));
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
// D3: in an empty document the transient is the only block and is never
|
|
6008
|
+
// removed.
|
|
6009
|
+
function transientIsOnlyBlock(el) {
|
|
6010
|
+
return contentEl.querySelectorAll('.ed-block').length === 1 && el.parentNode === contentEl;
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
// The `.ed-block` sibling right before `el`, transient or not, or null.
|
|
6014
|
+
function previousBlockEl(el) {
|
|
6015
|
+
let p = el.previousElementSibling;
|
|
6016
|
+
while (p && !p.classList.contains('ed-block')) p = p.previousElementSibling;
|
|
6017
|
+
return p;
|
|
6018
|
+
}
|
|
6019
|
+
|
|
6020
|
+
// The last block of `blockEl`'s subtree (itself for anything but a li with
|
|
6021
|
+
// deeper items after it). A transient on the way is stepped over, so a
|
|
6022
|
+
// transient sitting inside a subtree does not cut it short.
|
|
6023
|
+
function subtreeEndOf(blockEl) {
|
|
6024
|
+
const a = liAttrs(blockEl);
|
|
6025
|
+
if (!a) return blockEl;
|
|
6026
|
+
let end = blockEl;
|
|
6027
|
+
for (let n = blockEl.nextElementSibling; n; n = n.nextElementSibling) {
|
|
6028
|
+
if (!n.classList.contains('ed-block')) continue;
|
|
6029
|
+
if (n.hasAttribute('data-ed-transient')) continue;
|
|
6030
|
+
const na = liAttrs(n);
|
|
6031
|
+
if (!na || na.indent <= a.indent) break;
|
|
6032
|
+
end = n;
|
|
6033
|
+
}
|
|
6034
|
+
return end;
|
|
6035
|
+
}
|
|
6036
|
+
|
|
6037
|
+
// D2: the real block the transient's lines are committed after — the
|
|
6038
|
+
// previous `.ed-block[data-block-id]` sibling, or the end of its subtree
|
|
6039
|
+
// when it is a li — or null when nothing real precedes it.
|
|
6040
|
+
function transientAnchorEl(el) {
|
|
6041
|
+
let p = el.previousElementSibling;
|
|
6042
|
+
while (p && !(p.classList.contains('ed-block') && p.getAttribute('data-block-id') !== null)) {
|
|
6043
|
+
p = p.previousElementSibling;
|
|
6044
|
+
}
|
|
6045
|
+
return p ? subtreeEndOf(p) : null;
|
|
6046
|
+
}
|
|
6047
|
+
|
|
6048
|
+
function focusTransientStart(el) {
|
|
6049
|
+
const surface = blockContentEl(el);
|
|
6050
|
+
if (!surface) return;
|
|
6051
|
+
surface.focus();
|
|
6052
|
+
const range = document.createRange();
|
|
6053
|
+
range.selectNodeContents(surface);
|
|
6054
|
+
range.collapse(true);
|
|
6055
|
+
const sel = window.getSelection();
|
|
6056
|
+
sel.removeAllRanges();
|
|
6057
|
+
sel.addRange(range);
|
|
6058
|
+
}
|
|
6059
|
+
|
|
6060
|
+
// Builds the transient after `after` (an `.ed-block` child of `.content`),
|
|
6061
|
+
// or as the first child of `.content` when `after` is null. An existing
|
|
6062
|
+
// EMPTY transient is removed first (at most one, D3); when `after` IS that
|
|
6063
|
+
// empty transient, it is returned as it is (R20). `opts.focus` puts the
|
|
6064
|
+
// caret in it at offset 0, and the focusin that raises opens its burst.
|
|
6065
|
+
// Returns the element, or null when `after` is no longer in the document.
|
|
6066
|
+
function createTransient(after, opts) {
|
|
6067
|
+
const o = opts || {};
|
|
6068
|
+
if (after && transient && transient.el === after && after.isConnected && transientSurfaceEmpty(after)) {
|
|
6069
|
+
if (o.focus) focusTransientStart(after);
|
|
6070
|
+
return after;
|
|
6071
|
+
}
|
|
6072
|
+
if (after && !after.isConnected) return null;
|
|
6073
|
+
if (transient && transient.el.isConnected && transientSurfaceEmpty(transient.el)) removeTransient();
|
|
6074
|
+
const el = document.createElement('div');
|
|
6075
|
+
el.className = 'ed-block';
|
|
6076
|
+
el.setAttribute('data-block-type', 'paragraph');
|
|
6077
|
+
el.setAttribute('data-ed-transient', '1');
|
|
6078
|
+
const surface = document.createElement('p');
|
|
6079
|
+
surface.className = 'ed-wys-armed';
|
|
6080
|
+
surface.setAttribute('contenteditable', 'true');
|
|
6081
|
+
surface.setAttribute('data-placeholder', PLACEHOLDER_TEXT);
|
|
6082
|
+
el.appendChild(surface);
|
|
6083
|
+
if (after) after.parentNode.insertBefore(el, after.nextSibling);
|
|
6084
|
+
else contentEl.insertBefore(el, contentEl.firstChild);
|
|
6085
|
+
transient = { el, surface, byEnter: !!o.byEnter, anchorCaret: o.anchorCaret || null,
|
|
6086
|
+
stayEl: o.stayEl || null };
|
|
6087
|
+
syncEmptyMark(surface);
|
|
6088
|
+
if (o.focus) focusTransientStart(el);
|
|
6089
|
+
return el;
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
// v3.14.0 T4: data-ed-empty on a paragraph surface that holds no text and no
|
|
6093
|
+
// image, off otherwise. The CSS hint keys off it. Called on focusin and
|
|
6094
|
+
// input of an armed paragraph surface, by createTransient(), and after the
|
|
6095
|
+
// places that rewrite a surface's innerHTML (burst undo / redo, Escape).
|
|
6096
|
+
function syncEmptyMark(surface) {
|
|
6097
|
+
if (!surface || surface.nodeType !== 1) return;
|
|
6098
|
+
if (surface.textContent === '' && !surface.querySelector('img')) surface.setAttribute('data-ed-empty', '1');
|
|
6099
|
+
else surface.removeAttribute('data-ed-empty');
|
|
6100
|
+
}
|
|
6101
|
+
function wireEmptyMark() {
|
|
6102
|
+
const handler = (e) => {
|
|
6103
|
+
const t = e.target;
|
|
6104
|
+
const surface = t && t.closest
|
|
6105
|
+
? t.closest('.ed-block[data-block-type="paragraph"] > .ed-wys-armed') : null;
|
|
6106
|
+
if (surface) syncEmptyMark(surface);
|
|
6107
|
+
};
|
|
6108
|
+
document.addEventListener('focusin', handler, true);
|
|
6109
|
+
document.addEventListener('input', handler, true);
|
|
6110
|
+
}
|
|
6111
|
+
|
|
6112
|
+
// A press that blurs an empty transient must not move the page under
|
|
6113
|
+
// itself. Its mousedown is what resolves the transient's burst (focusout),
|
|
6114
|
+
// and removing the element right there pulls every block below it up by
|
|
6115
|
+
// its height before the mouseup — the click then lands on whatever is
|
|
6116
|
+
// under the pointer now, usually the gap or `main.content`, and the block
|
|
6117
|
+
// click delegator reads that as "clicked outside any block" and resolves
|
|
6118
|
+
// the burst the press had just opened on the block it really hit. MEASURED
|
|
6119
|
+
// (ER "empty-Enter on the ONLY item…", both engines in the click check
|
|
6120
|
+
// 'a press on the paragraph below an empty transient…'): the follow-up
|
|
6121
|
+
// typing was never committed. So while a button is down the removal waits
|
|
6122
|
+
// for the press to end (mouseup, then the click it dispatches), and only
|
|
6123
|
+
// removes the transient if nothing has claimed it meanwhile.
|
|
6124
|
+
let pointerPressed = false;
|
|
6125
|
+
const afterPressQueue = [];
|
|
6126
|
+
document.addEventListener('mousedown', () => { pointerPressed = true; }, true);
|
|
6127
|
+
const endPress = () => {
|
|
6128
|
+
pointerPressed = false;
|
|
6129
|
+
if (!afterPressQueue.length) return;
|
|
6130
|
+
const due = afterPressQueue.splice(0);
|
|
6131
|
+
setTimeout(() => due.forEach((fn) => fn()), 0);
|
|
6132
|
+
};
|
|
6133
|
+
document.addEventListener('mouseup', endPress, true);
|
|
6134
|
+
// v3.14.0 T4: a press can end without a mouseup (a native drag takes the
|
|
6135
|
+
// pointer, the window loses it), which used to leave the flag set until the
|
|
6136
|
+
// next click; those endings clear it too.
|
|
6137
|
+
document.addEventListener('pointercancel', endPress, true);
|
|
6138
|
+
document.addEventListener('dragend', endPress, true);
|
|
6139
|
+
window.addEventListener('blur', endPress);
|
|
6140
|
+
function removeTransientAfterPress(el) {
|
|
6141
|
+
const drop = () => {
|
|
6142
|
+
if (!transient || transient.el !== el || !el.isConnected) return;
|
|
6143
|
+
if (el.contains(document.activeElement) || !transientSurfaceEmpty(el)) return;
|
|
6144
|
+
if (transientIsOnlyBlock(el)) return;
|
|
6145
|
+
removeTransient();
|
|
6146
|
+
};
|
|
6147
|
+
if (pointerPressed) afterPressQueue.push(drop);
|
|
6148
|
+
else drop();
|
|
6149
|
+
}
|
|
6150
|
+
|
|
6151
|
+
// DOM only — no op, nothing written. Its burst (if open) ends first, so the
|
|
6152
|
+
// focusout the removal raises finds nothing to resolve.
|
|
6153
|
+
function removeTransient() {
|
|
6154
|
+
const t = transient;
|
|
6155
|
+
transient = null;
|
|
6156
|
+
if (!t) return;
|
|
6157
|
+
if (currentBurst && currentBurst.blockEl === t.el) endBurstWithoutResolve();
|
|
6158
|
+
if (t.el.parentNode) t.el.parentNode.removeChild(t.el);
|
|
6159
|
+
}
|
|
6160
|
+
|
|
6161
|
+
// Commits `newLines` at the transient's position: one op
|
|
6162
|
+
// (commitInsertionAt()), then a render. Keys held during that render that
|
|
6163
|
+
// were typed into the transient follow it to the line it lands on
|
|
6164
|
+
// (`provisional`). On success `transientLandedLine` names that line; on
|
|
6165
|
+
// failure `lines` is rolled back and the transient — with whatever the
|
|
6166
|
+
// user typed — stays on screen for the next attempt.
|
|
6167
|
+
async function commitLinesAtTransient(el, newLines) {
|
|
6168
|
+
const anchorEl = transientAnchorEl(el);
|
|
6169
|
+
if (anchorEl && blockOwnsNoLine(anchorEl)) {
|
|
6170
|
+
refuseStructuralListEdit(noSourceLineInsertMessageFor(anchorEl));
|
|
6171
|
+
return { ok: false, newStartLine: null };
|
|
6172
|
+
}
|
|
6173
|
+
const anchorId = anchorEl ? Number(anchorEl.getAttribute('data-block-id')) : null;
|
|
6174
|
+
const result = commitInsertionAt({ lines, blocks, stack }, anchorId, newLines);
|
|
6175
|
+
if (result.op === null) return { ok: false, newStartLine: null };
|
|
6176
|
+
const prevLines = lines;
|
|
6177
|
+
lines = result.lines;
|
|
6178
|
+
const editRange = { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6179
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6180
|
+
delta: result.op.after.length - result.op.before.length };
|
|
6181
|
+
// Fix round 1 (I1): the commit is a pure insertion, so every block that
|
|
6182
|
+
// starts at or after the op's first line moves down by its delta —
|
|
6183
|
+
// including, at the top of the document, the first block whose line the
|
|
6184
|
+
// op re-states. Named here by id, because editRange alone cannot say it
|
|
6185
|
+
// for that first block (its line is INSIDE the edited range).
|
|
6186
|
+
const shifted = {};
|
|
6187
|
+
blocks.forEach((b) => {
|
|
6188
|
+
if (b.startLine >= result.op.startLine) shifted[b.id] = b.startLine + editRange.delta;
|
|
6189
|
+
});
|
|
6190
|
+
pendingFlightLineMap = { ids: shifted, provisional: result.newStartLine };
|
|
6191
|
+
const ok = await safeRerenderAll(editRange);
|
|
6192
|
+
if (!ok) {
|
|
6193
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6194
|
+
return { ok: false, newStartLine: null };
|
|
6195
|
+
}
|
|
6196
|
+
transientLandedLine = result.newStartLine;
|
|
6197
|
+
transientCommits.seq++;
|
|
6198
|
+
transientCommits.log.push({ seq: transientCommits.seq, at: result.op.startLine, delta: editRange.delta });
|
|
6199
|
+
if (transientCommits.log.length > 8) transientCommits.log.shift();
|
|
6200
|
+
return { ok: true, newStartLine: result.newStartLine };
|
|
6201
|
+
}
|
|
6202
|
+
|
|
6203
|
+
// resolveBurst()'s transient branch. Empty: kept (burst restarted) while
|
|
6204
|
+
// focus is still inside it — Ctrl+S or a toolbar press right after making
|
|
6205
|
+
// it must not eat it — or while it is the document's only block; removed
|
|
6206
|
+
// otherwise (D3). With text: committed, and the caret follows it to the
|
|
6207
|
+
// committed paragraph's end when the resolution began with focus inside.
|
|
6208
|
+
async function resolveTransientBurst(burst) {
|
|
6209
|
+
const el = burst.blockEl;
|
|
6210
|
+
const surface = burst.editEl;
|
|
6211
|
+
const focusInside = el.contains(document.activeElement);
|
|
6212
|
+
burst.history.flushTyping();
|
|
6213
|
+
// An emptied surface a browser left a <br> in serializes as '<br>': it
|
|
6214
|
+
// holds no text and no image, so it is empty (T4: Ctrl+S after typing and
|
|
6215
|
+
// erasing everything wrote that line).
|
|
6216
|
+
const md = transientSurfaceEmpty(el) ? ''
|
|
6217
|
+
: inlineMd.escapeBlockStarts(inlineMd.serializeInline(surface).md).replace(/\s+$/, '');
|
|
6218
|
+
if (md === '') {
|
|
6219
|
+
endBurstWithoutResolve();
|
|
6220
|
+
if (!el.isConnected) {
|
|
6221
|
+
if (transient && transient.el === el) transient = null;
|
|
6222
|
+
return true;
|
|
6223
|
+
}
|
|
6224
|
+
if (focusInside) {
|
|
6225
|
+
// A <br> a browser leaves behind in an emptied surface is not content.
|
|
6226
|
+
if (surface.innerHTML !== '') { surface.innerHTML = ''; syncEmptyMark(surface); focusTransientStart(el); }
|
|
6227
|
+
startBurst(surface);
|
|
6228
|
+
return true;
|
|
6229
|
+
}
|
|
6230
|
+
if (transientIsOnlyBlock(el)) return true;
|
|
6231
|
+
removeTransientAfterPress(el);
|
|
6232
|
+
return true;
|
|
6233
|
+
}
|
|
6234
|
+
const res = await commitLinesAtTransient(el, md.split('\n'));
|
|
6235
|
+
if (!res.ok) return false;
|
|
6236
|
+
if (focusInside) focusBlockAtLine(res.newStartLine, true);
|
|
6237
|
+
return true;
|
|
6238
|
+
}
|
|
6239
|
+
|
|
6240
|
+
// 在下方插入 / + 段落: a transient below `blockEl` (below its whole
|
|
6241
|
+
// subtree for a li). Same preamble as insertBlockBelow(): resolve whatever
|
|
6242
|
+
// is open, then re-resolve the block if that resolution re-rendered it.
|
|
6243
|
+
async function insertTransientBelow(blockEl) {
|
|
6244
|
+
if (!blockEl) return;
|
|
6245
|
+
const live = await liveBlockAfterSwitch(blockEl);
|
|
6246
|
+
if (!live) return;
|
|
6247
|
+
createTransient(subtreeEndOf(live), { focus: true });
|
|
6248
|
+
}
|
|
6249
|
+
|
|
6250
|
+
// 在上方插入: a transient right above `blockEl`, i.e. after the block before
|
|
6251
|
+
// it — after that block's whole subtree when it is a li, which is also
|
|
6252
|
+
// where its commit lands (D2), so the screen never shows it anywhere the
|
|
6253
|
+
// file will not.
|
|
6254
|
+
async function insertTransientAbove(blockEl) {
|
|
6255
|
+
if (!blockEl) return;
|
|
6256
|
+
const live = await liveBlockAfterSwitch(blockEl);
|
|
6257
|
+
if (!live) return;
|
|
6258
|
+
const prev = previousBlockEl(live);
|
|
6259
|
+
createTransient(prev ? (isTransient(prev) ? prev : subtreeEndOf(prev)) : null, { focus: true });
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
// switchAwayFrom(), then the live element for `blockEl` (null = abandon).
|
|
6263
|
+
async function liveBlockAfterSwitch(blockEl) {
|
|
6264
|
+
const identity = captureBlockIdentity(blockEl);
|
|
6265
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
6266
|
+
const ok = await switchAwayFrom();
|
|
6267
|
+
if (!ok) return null;
|
|
6268
|
+
if (document.body.contains(blockEl)) return blockEl;
|
|
6269
|
+
const live = reresolveBlockEl(identity) ||
|
|
6270
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
6271
|
+
if (!live) showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
6272
|
+
return live;
|
|
6273
|
+
}
|
|
6274
|
+
|
|
6275
|
+
// ── v3.14.0 T4: the toolbar on a transient paragraph ──────────────────
|
|
6276
|
+
//
|
|
6277
|
+
// toolbarOnTransient(action, el) → true when the press was handled here,
|
|
6278
|
+
// false when the caller should run its normal action (R8: this is the one
|
|
6279
|
+
// implementation of 在上方/在下方插入 on a transient; T1's earlier branch is
|
|
6280
|
+
// gone). `action` is a toolbar button id, or 'h1'..'h6' for the headings
|
|
6281
|
+
// menu. The inline mark ids, undo, redo, save, outline and the headings
|
|
6282
|
+
// opener are not block actions and always return false.
|
|
6283
|
+
// with text commit it (switchAwayFrom), re-target the bar at the block
|
|
6284
|
+
// it became, return false so the normal action runs on that
|
|
6285
|
+
// empty insert-after / insert-before: the caret goes (back) into it;
|
|
6286
|
+
// table / code / list / h1-h6 / line: its position takes the
|
|
6287
|
+
// skeleton (insertAtTransient); image: false (the picker's
|
|
6288
|
+
// anchor is the transient, insertImages() commits there);
|
|
6289
|
+
// quote / numbered / check / indent / outdent: disabled by
|
|
6290
|
+
// the model, a no-op here
|
|
6291
|
+
const TOOLBAR_TRANSIENT_PASS = new Set(['undo', 'redo', 'outline', 'save', 'headings',
|
|
6292
|
+
'bold', 'italic', 'strike', 'inline-code', 'link']);
|
|
6293
|
+
async function toolbarOnTransient(action, el) {
|
|
6294
|
+
if (!el || !isTransient(el) || !el.isConnected) return false;
|
|
6295
|
+
if (TOOLBAR_TRANSIENT_PASS.has(action)) return false;
|
|
6296
|
+
if (!transientSurfaceEmpty(el)) {
|
|
6297
|
+
transientLandedLine = null;
|
|
6298
|
+
const ok = await switchAwayFrom();
|
|
6299
|
+
if (!ok || transientLandedLine === null) return true;
|
|
6300
|
+
const landed = blockElAtLine(transientLandedLine);
|
|
6301
|
+
if (!landed) return true;
|
|
6302
|
+
toolbarBlockEl = landed;
|
|
6303
|
+
return false;
|
|
6304
|
+
}
|
|
6305
|
+
switch (action) {
|
|
6306
|
+
case 'insert-after':
|
|
6307
|
+
case 'insert-before':
|
|
6308
|
+
focusTransientStart(el);
|
|
6309
|
+
return true;
|
|
6310
|
+
case 'table': case 'code': case 'list': case 'line':
|
|
6311
|
+
await insertAtTransient(el, action);
|
|
6312
|
+
return true;
|
|
6313
|
+
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
|
|
6314
|
+
await insertAtTransient(el, 'heading', Number(action.slice(1)));
|
|
6315
|
+
return true;
|
|
6316
|
+
case 'image':
|
|
6317
|
+
return false;
|
|
6318
|
+
default:
|
|
6319
|
+
return true;
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
|
|
6323
|
+
// Commits BLOCK_SKELETONS[kind] (a heading builds its own marker) at the
|
|
6324
|
+
// transient's position through commitLinesAtTransient(), as one op, then
|
|
6325
|
+
// focuses it the way insertBlockBelow() does — pristineInsert for the four
|
|
6326
|
+
// placeholder kinds, none for 'line', which instead gets a fresh transient
|
|
6327
|
+
// right after the rule so the caret has somewhere to go. One exception: a
|
|
6328
|
+
// 清單 right below a list item continues that list (insertBlockBelow's li
|
|
6329
|
+
// branch) instead of writing a blank-separated item that would make the
|
|
6330
|
+
// list loose.
|
|
6331
|
+
async function insertAtTransient(el, kind, depth) {
|
|
6332
|
+
const skeleton = kind === 'heading' ? ['#'.repeat(depth || 1) + ' '] : BLOCK_SKELETONS[kind];
|
|
6333
|
+
if (!skeleton) return;
|
|
6334
|
+
if (kind === 'list') {
|
|
6335
|
+
const anchor = transientAnchorEl(el);
|
|
6336
|
+
if (anchor && anchor.getAttribute('data-block-type') === 'li') {
|
|
6337
|
+
removeTransient();
|
|
6338
|
+
await insertBlockBelow(anchor, 'list');
|
|
6339
|
+
return;
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6342
|
+
const res = await commitLinesAtTransient(el, skeleton.slice());
|
|
6343
|
+
if (!res.ok) return;
|
|
6344
|
+
if (kind === 'line') {
|
|
6345
|
+
const hr = blockElAtLine(res.newStartLine);
|
|
6346
|
+
if (hr) createTransient(hr, { focus: true });
|
|
6347
|
+
return;
|
|
6348
|
+
}
|
|
6349
|
+
const target = blocks.find((b) => b.startLine === res.newStartLine);
|
|
6350
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
6351
|
+
await focusInsertedBlock(res.newStartLine, kind);
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
// The transient's text as source markdown ('' when it holds none) — the
|
|
6355
|
+
// same serialization resolveTransientBurst() commits.
|
|
6356
|
+
function transientTextMd(el) {
|
|
6357
|
+
const surface = blockContentEl(el);
|
|
6358
|
+
if (!surface || transientSurfaceEmpty(el)) return '';
|
|
6359
|
+
return inlineMd.escapeBlockStarts(inlineMd.serializeInline(surface).md).replace(/\s+$/, '');
|
|
6360
|
+
}
|
|
6361
|
+
|
|
6362
|
+
// Block markdown (a pasted structure, a dropped or picked image) replaces
|
|
6363
|
+
// the transient: its own text first when it has some, a blank line, then
|
|
6364
|
+
// `pastedLines` — all one commit, one op. The caret ends at the end of the
|
|
6365
|
+
// last inserted block. Returns whether the commit landed.
|
|
6366
|
+
async function commitPastedLinesAtTransient(el, pastedLines) {
|
|
6367
|
+
const surface = blockContentEl(el);
|
|
6368
|
+
try {
|
|
6369
|
+
return await commitPastedLinesAtTransientBody(el, pastedLines, surface);
|
|
6370
|
+
} catch (e) {
|
|
6371
|
+
// Fix round 1 (I1): a throw must not leave the typed text on screen with
|
|
6372
|
+
// no burst to commit it (R32). The burst is restarted and the card shown.
|
|
6373
|
+
if (el.isConnected && surface && !currentBurst) startBurst(surface);
|
|
6374
|
+
showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
|
|
6375
|
+
')。文件維持修改前的樣子。', null, null);
|
|
6376
|
+
return false;
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
async function commitPastedLinesAtTransientBody(el, pastedLines, surface) {
|
|
6380
|
+
const text = transientTextMd(el);
|
|
6381
|
+
const all = (text ? text.split('\n').concat(['']) : []).concat(pastedLines);
|
|
6382
|
+
// The text is committed here, with the paste; the burst that holds it must
|
|
6383
|
+
// not resolve it a second time when the render detaches the surface (the
|
|
6384
|
+
// focusout that raises runs before applyFullRender() clears the burst —
|
|
6385
|
+
// MEASURED in Chromium: the typed text was written twice).
|
|
6386
|
+
if (currentBurst && currentBurst.blockEl === el) endBurstWithoutResolve();
|
|
6387
|
+
const res = await commitLinesAtTransient(el, all);
|
|
6388
|
+
if (!res.ok) {
|
|
6389
|
+
if (el.isConnected && surface && !currentBurst) startBurst(surface);
|
|
6390
|
+
return false;
|
|
6391
|
+
}
|
|
6392
|
+
const lastLine = res.newStartLine + all.length - 1;
|
|
6393
|
+
let last = null;
|
|
6394
|
+
blocks.forEach((b) => { if (b.startLine <= lastLine) last = b; });
|
|
6395
|
+
if (last) focusBlockAtLine(last.startLine, true);
|
|
6396
|
+
return true;
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
// An empty document has no block to type into: one focused transient is
|
|
6400
|
+
// its first paragraph (it is the only block, so D3 never removes it).
|
|
6401
|
+
function ensureEmptyDocTransient() {
|
|
6402
|
+
if (contentEl.querySelector('.ed-block')) return;
|
|
6403
|
+
createTransient(null, { focus: true });
|
|
6404
|
+
}
|
|
6405
|
+
|
|
6406
|
+
// Mousedown on the page below the last block starts a paragraph there.
|
|
6407
|
+
// Mousedown with preventDefault (R19), not pointerdown: the press must not
|
|
6408
|
+
// move focus, or the transient made here would be blurred away at once. It
|
|
6409
|
+
// acts only on the page's own empty surfaces (an allow-list), never on a
|
|
6410
|
+
// point that elementsFromPoint says is a block or any chrome; comparing the
|
|
6411
|
+
// y against the last block's bottom is region classification after that.
|
|
6412
|
+
// It does not read pointerPressed.
|
|
6413
|
+
function wireClickBelow() {
|
|
6414
|
+
document.addEventListener('mousedown', (e) => {
|
|
6415
|
+
clickBelowOnMousedown(e).catch((err) => {
|
|
6416
|
+
// Fix round 1 (I2): a rejection after preventDefault() must show the
|
|
6417
|
+
// card, not vanish as an unhandled rejection.
|
|
6418
|
+
showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(err) +
|
|
6419
|
+
')。文件維持修改前的樣子。', null, null);
|
|
6420
|
+
});
|
|
6421
|
+
});
|
|
6422
|
+
}
|
|
6423
|
+
async function clickBelowOnMousedown(e) {
|
|
6424
|
+
{
|
|
6425
|
+
if (e.button !== 0 || e.defaultPrevented || waveEditorIsModal()) return;
|
|
6426
|
+
const t = e.target;
|
|
6427
|
+
const surfaceTarget = t === document.documentElement || t === document.body ||
|
|
6428
|
+
(t.classList && t.classList.contains('page-layout')) ||
|
|
6429
|
+
(t.matches && t.matches('main.content'));
|
|
6430
|
+
if (!surfaceTarget) return;
|
|
6431
|
+
const hits = document.elementsFromPoint(e.clientX, e.clientY);
|
|
6432
|
+
if (hits.some((h) => h.closest &&
|
|
6433
|
+
h.closest('.ed-block, .reader-sidebar, .ed-toolbar, .ed-conflict, .md2doc-theme-toggle'))) return;
|
|
6434
|
+
const all = contentEl.querySelectorAll('.ed-block');
|
|
6435
|
+
if (!all.length) return;
|
|
6436
|
+
if (e.clientY <= all[all.length - 1].getBoundingClientRect().bottom) return;
|
|
6437
|
+
e.preventDefault();
|
|
6438
|
+
const focusLastOrCreate = () => {
|
|
6439
|
+
const now = contentEl.querySelectorAll('.ed-block');
|
|
6440
|
+
const last = now[now.length - 1];
|
|
6441
|
+
if (!last) return;
|
|
6442
|
+
if (!isTransient(last)) { createTransient(last, { focus: true }); return; }
|
|
6443
|
+
// Final review M6: one that holds typed text takes the caret at the
|
|
6444
|
+
// end of it, where a click below the last line lands.
|
|
6445
|
+
const surface = blockContentEl(last);
|
|
6446
|
+
if (surface && !transientSurfaceEmpty(last)) caretToSurface(surface, true);
|
|
6447
|
+
else focusTransientStart(last);
|
|
6448
|
+
};
|
|
6449
|
+
if (isTransient(all[all.length - 1])) { focusLastOrCreate(); return; }
|
|
6450
|
+
if (!(await switchAwayFrom())) return;
|
|
6451
|
+
focusLastOrCreate();
|
|
6452
|
+
}
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
// ── v3.14.0 T2: Enter in a paragraph or a heading ─────────────────────
|
|
6456
|
+
//
|
|
6457
|
+
// Plain Enter splits the block at the caret: the left half keeps the block,
|
|
6458
|
+
// the right half becomes a paragraph below it, and the caret goes to the
|
|
6459
|
+
// right half's start (spec §3.1). At an edge nothing is split: at the end a
|
|
6460
|
+
// transient paragraph opens below with the caret in it, at the start one
|
|
6461
|
+
// opens above and the caret stays. One Enter is one undo step; typing it
|
|
6462
|
+
// commits first is its own earlier step (D4). Whitespace at the split point
|
|
6463
|
+
// is dropped from both halves, and so is a trailing <br> on the left (D5).
|
|
6464
|
+
//
|
|
6465
|
+
// The split goes through the v3.13.1 flight guard: the DOM is split and the
|
|
6466
|
+
// right half armed and focused synchronously, before the commit's render is
|
|
6467
|
+
// awaited, so keys typed meanwhile are held and replayed into the new
|
|
6468
|
+
// paragraph. The two edge cases await the commit of pending typing BEFORE
|
|
6469
|
+
// the transient is created (R7): that commit's render would drop a
|
|
6470
|
+
// transient made first.
|
|
6471
|
+
|
|
6472
|
+
// Where a collapsed caret sits in `surface`: 'start' (nothing before it),
|
|
6473
|
+
// 'end' (nothing after it, a trailing <br> that is the surface's last child
|
|
6474
|
+
// aside), 'empty' (both), 'middle', or null when the selection is not a
|
|
6475
|
+
// collapsed caret inside `surface`. "Nothing" is no <br>, no <img> and no
|
|
6476
|
+
// text but whitespace — whitespace at the split point is dropped anyway, so
|
|
6477
|
+
// a caret after a trailing space is at the end. `strict` (Backspace /
|
|
6478
|
+
// Delete) counts whitespace as text: there the key would delete it.
|
|
6479
|
+
function caretPlace(surface, strict) {
|
|
6480
|
+
const sel = window.getSelection();
|
|
6481
|
+
if (!surface || !sel.rangeCount || !sel.isCollapsed) return null;
|
|
6482
|
+
const r = sel.getRangeAt(0);
|
|
6483
|
+
if (!surface.contains(r.startContainer)) return null;
|
|
6484
|
+
const before = document.createRange();
|
|
6485
|
+
before.selectNodeContents(surface);
|
|
6486
|
+
before.setEnd(r.startContainer, r.startOffset);
|
|
6487
|
+
const after = document.createRange();
|
|
6488
|
+
after.selectNodeContents(surface);
|
|
6489
|
+
after.setStart(r.startContainer, r.startOffset);
|
|
6490
|
+
const last = surface.lastChild;
|
|
6491
|
+
if (last && last.nodeName === 'BR' && after.isPointInRange(last, 0)) after.setEndBefore(last);
|
|
6492
|
+
const holds = (range) => {
|
|
6493
|
+
const frag = range.cloneContents();
|
|
6494
|
+
const text = strict ? frag.textContent : frag.textContent.replace(/\s+/g, '');
|
|
6495
|
+
return text !== '' || !!frag.querySelector('br, img');
|
|
6496
|
+
};
|
|
6497
|
+
const b = holds(before);
|
|
6498
|
+
const a = holds(after);
|
|
6499
|
+
if (!b && !a) return 'empty';
|
|
6500
|
+
if (!b) return 'start';
|
|
6501
|
+
if (!a) return 'end';
|
|
6502
|
+
return 'middle';
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
// R4: the text a paragraph / heading surface commits as — the heading's
|
|
6506
|
+
// own `#` run in front, a paragraph's line-start markers escaped (F-1).
|
|
6507
|
+
// resolveBurst() uses the same rule; split and merge serialize through it.
|
|
6508
|
+
function commitTextOf(md, blockType, depth) {
|
|
6509
|
+
if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + md;
|
|
6510
|
+
return blockType === 'paragraph' ? inlineMd.escapeBlockStarts(md) : md;
|
|
6511
|
+
}
|
|
6512
|
+
function surfaceCommitText(surface, blockType, depth) {
|
|
6513
|
+
return commitTextOf(inlineMd.serializeInline(surface).md, blockType, depth);
|
|
6514
|
+
}
|
|
6515
|
+
function burstCommitText(burst, surface) {
|
|
6516
|
+
return surfaceCommitText(surface || burst.editEl, burst.blockType, burst.depth);
|
|
6517
|
+
}
|
|
6518
|
+
|
|
6519
|
+
// Inline elements holding nothing a serializer emits (no text, no <br>, no
|
|
6520
|
+
// <img>) are removed, deepest first — the empty `<a>` or `<strong>` a split
|
|
6521
|
+
// right at a mark's edge leaves on one side would otherwise come back as
|
|
6522
|
+
// `[](url)` or `****`.
|
|
6523
|
+
function dropEmptyInlines(root) {
|
|
6524
|
+
Array.prototype.slice.call(root.querySelectorAll('*')).reverse().forEach((el) => {
|
|
6525
|
+
if (el.nodeName === 'BR' || el.nodeName === 'IMG') return;
|
|
6526
|
+
if (el.textContent === '' && !el.querySelector('br, img')) el.parentNode.removeChild(el);
|
|
6527
|
+
});
|
|
6528
|
+
}
|
|
6529
|
+
// The first (`atEnd` false) or last leaf node under `root`, or null.
|
|
6530
|
+
function edgeLeaf(root, atEnd) {
|
|
6531
|
+
let n = atEnd ? root.lastChild : root.firstChild;
|
|
6532
|
+
while (n && n.nodeType === 1 && (atEnd ? n.lastChild : n.firstChild)) n = atEnd ? n.lastChild : n.firstChild;
|
|
6533
|
+
return n;
|
|
6534
|
+
}
|
|
6535
|
+
// D5 on one half: its whitespace at the split point (the end of the left
|
|
6536
|
+
// half, the start of the right one) — a soft-wrap newline included — plus
|
|
6537
|
+
// one hard break (<br>) on that edge. Fix round 1 (C2): the newline was
|
|
6538
|
+
// kept, so a split at a soft wrap wrote an extra blank line, the right half
|
|
6539
|
+
// landed one line below where the caret was sent, and the next key was
|
|
6540
|
+
// lost (measured: `Alpha one\n\n\nbravo two.`, caret null).
|
|
6541
|
+
function trimSplitEdge(root, atEnd) {
|
|
6542
|
+
root.normalize();
|
|
6543
|
+
dropEmptyInlines(root);
|
|
6544
|
+
const br = edgeLeaf(root, atEnd);
|
|
6545
|
+
if (br && br.nodeName === 'BR') { br.parentNode.removeChild(br); dropEmptyInlines(root); }
|
|
6546
|
+
for (;;) {
|
|
6547
|
+
const leaf = edgeLeaf(root, atEnd);
|
|
6548
|
+
if (!leaf || leaf.nodeType !== 3) return;
|
|
6549
|
+
const t = atEnd ? leaf.data.replace(/[ \t\r\n ]+$/, '') : leaf.data.replace(/^[ \t\r\n ]+/, '');
|
|
6550
|
+
if (t === leaf.data) return;
|
|
6551
|
+
if (t !== '') { leaf.data = t; return; }
|
|
6552
|
+
leaf.parentNode.removeChild(leaf);
|
|
6553
|
+
dropEmptyInlines(root);
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6557
|
+
// Moves [caret, end of `surface`] into a new provisional paragraph — an
|
|
6558
|
+
// id-less `.ed-block[data-block-type="paragraph"]` right after the source
|
|
6559
|
+
// block — and returns its <p>, or null when the caret is not in `surface`.
|
|
6560
|
+
// Range surgery like splitListItemAtCaret(), so a caret inside a mark or a
|
|
6561
|
+
// link gives each half its own copy of it. The new block owns no line until
|
|
6562
|
+
// the commit's render replaces it (startBurst() opens no burst on it, R2).
|
|
6563
|
+
function splitSurfaceAtCaret(surface) {
|
|
6564
|
+
const sel = window.getSelection();
|
|
6565
|
+
if (!sel.rangeCount) return null;
|
|
6566
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
6567
|
+
if (!surface.contains(range.startContainer)) return null;
|
|
6568
|
+
const blockEl = surface.closest('.ed-block');
|
|
6569
|
+
if (!blockEl || !blockEl.parentNode) return null;
|
|
6570
|
+
const leftovers = collectLeftovers(range, surface);
|
|
6571
|
+
if (!range.collapsed) range.deleteContents();
|
|
6572
|
+
const tail = document.createRange();
|
|
6573
|
+
tail.setStart(range.startContainer, range.startOffset);
|
|
6574
|
+
tail.setEnd(surface, surface.childNodes.length);
|
|
6575
|
+
collectLeftovers(tail, surface, leftovers);
|
|
6576
|
+
const frag = tail.extractContents();
|
|
6577
|
+
const newBlock = document.createElement('div');
|
|
6578
|
+
newBlock.className = 'ed-block';
|
|
6579
|
+
newBlock.setAttribute('data-block-type', 'paragraph');
|
|
6580
|
+
const right = document.createElement('p');
|
|
6581
|
+
right.appendChild(frag);
|
|
6582
|
+
newBlock.appendChild(right);
|
|
6583
|
+
blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
|
|
6584
|
+
dropEmptied(leftovers);
|
|
6585
|
+
trimSplitEdge(surface, true);
|
|
6586
|
+
trimSplitEdge(right, false);
|
|
6587
|
+
return right;
|
|
6588
|
+
}
|
|
6589
|
+
|
|
6590
|
+
// The two halves of `surface` at the caret as detached copies, D5 applied
|
|
6591
|
+
// — for a transient, whose split is committed before anything is drawn.
|
|
6592
|
+
function halvesAtCaret(surface) {
|
|
6593
|
+
const at = window.getSelection().getRangeAt(0);
|
|
6594
|
+
const half = (left) => {
|
|
6595
|
+
const range = document.createRange();
|
|
6596
|
+
range.selectNodeContents(surface);
|
|
6597
|
+
if (left) range.setEnd(at.startContainer, at.startOffset);
|
|
6598
|
+
else range.setStart(at.startContainer, at.startOffset);
|
|
6599
|
+
const el = document.createElement('p');
|
|
6600
|
+
el.appendChild(range.cloneContents());
|
|
6601
|
+
trimSplitEdge(el, left);
|
|
6602
|
+
return el;
|
|
6603
|
+
};
|
|
6604
|
+
return { left: half(true), right: half(false) };
|
|
6605
|
+
}
|
|
6606
|
+
|
|
6607
|
+
// A structural key moves focus off (or detaches) the focused surface while
|
|
6608
|
+
// its burst is still current; the focusout that raises must not resolve the
|
|
6609
|
+
// burst — the key commits it itself. Same flag and same reason as
|
|
6610
|
+
// mutateListRun(), without the list-run bookkeeping.
|
|
6611
|
+
function withFocusoutSuppressed(fn) {
|
|
6612
|
+
suppressLiFocusout = true;
|
|
6613
|
+
try {
|
|
6614
|
+
return fn();
|
|
6615
|
+
} finally {
|
|
6616
|
+
suppressLiFocusout = false;
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
|
|
6620
|
+
// Plain Enter in a paragraph, heading or transient surface (Shift+Enter is
|
|
6621
|
+
// a <br>; a chord still commits by blurring).
|
|
6622
|
+
async function paragraphEnter(e, editEl) {
|
|
6623
|
+
// R / Review Focus 4: an IME's own confirming Enter is the IME's.
|
|
6624
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
6625
|
+
e.preventDefault();
|
|
6626
|
+
const burst = currentBurst;
|
|
6627
|
+
if (!burst || burst.editEl !== editEl) return;
|
|
6628
|
+
// Content the inline serializer cannot write: the old commit-and-leave,
|
|
6629
|
+
// whose resolution falls back to the source editor.
|
|
6630
|
+
if (inlineMd.serializeInline(editEl).unsupported.length) { editEl.blur(); return; }
|
|
6631
|
+
// A selection standing in the surface is deleted first, one burst step.
|
|
6632
|
+
const sel = window.getSelection();
|
|
6633
|
+
if (sel.rangeCount && !sel.isCollapsed) {
|
|
6634
|
+
const r = sel.getRangeAt(0);
|
|
6635
|
+
if (!editEl.contains(r.startContainer) || !editEl.contains(r.endContainer)) return;
|
|
6636
|
+
snapBurstIfActive(editEl, 'enter-pre');
|
|
6637
|
+
const leftovers = collectLeftovers(r, editEl);
|
|
6638
|
+
r.deleteContents();
|
|
6639
|
+
dropEmptied(leftovers);
|
|
6640
|
+
sel.removeAllRanges();
|
|
6641
|
+
sel.addRange(r);
|
|
6642
|
+
snapBurstIfActive(editEl, 'enter');
|
|
6643
|
+
}
|
|
6644
|
+
const place = caretPlace(editEl);
|
|
6645
|
+
if (!place) return;
|
|
6646
|
+
const blockEl = editEl.closest('.ed-block');
|
|
6647
|
+
// Fix round 1 (C1): an emptied real block is one removal op and a
|
|
6648
|
+
// focused transient where it was. Committing it as text wrote a blank (or
|
|
6649
|
+
// a `<br>` html block), took the block away, and the lookup by start line
|
|
6650
|
+
// then found nothing — no transient, no caret, the next keys lost.
|
|
6651
|
+
if (place === 'empty' && !burst.transient) { await replaceEmptiedBlockWithTransient(burst, editEl); return; }
|
|
6652
|
+
if (burst.transient) {
|
|
6653
|
+
if (place === 'empty') return;
|
|
6654
|
+
if (place === 'middle') { await splitTransientAtCaret(blockEl, editEl); return; }
|
|
6655
|
+
}
|
|
6656
|
+
if (place === 'middle') { await splitBlockAtCaret(burst, editEl, blockEl); return; }
|
|
6657
|
+
const live = await settleEnterSource(burst, blockEl);
|
|
6658
|
+
if (!live) return;
|
|
6659
|
+
const surface = blockContentEl(live);
|
|
6660
|
+
if (place === 'start') {
|
|
6661
|
+
createTransient(previousBlockEl(live), { focus: false, byEnter: true, stayEl: live });
|
|
6662
|
+
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
6663
|
+
if (document.activeElement !== surface) surface.focus();
|
|
6664
|
+
const r = document.createRange();
|
|
6665
|
+
r.selectNodeContents(surface);
|
|
6666
|
+
r.collapse(true);
|
|
6667
|
+
sel.removeAllRanges();
|
|
6668
|
+
sel.addRange(r);
|
|
6669
|
+
// R3: a surface that kept focus through the commit gets no focusin, and
|
|
6670
|
+
// its burst ended with that commit.
|
|
6671
|
+
if (!currentBurst) startBurst(surface);
|
|
6672
|
+
return;
|
|
6673
|
+
}
|
|
6674
|
+
createTransient(subtreeEndOf(live), { focus: true, byEnter: true,
|
|
6675
|
+
anchorCaret: surface ? { surface, offset: surface.textContent.length } : null });
|
|
6676
|
+
}
|
|
6677
|
+
|
|
6678
|
+
// The edge cases' first step: commit what is pending in the block Enter was
|
|
6679
|
+
// pressed in (R7) and hand back the live element it is now — re-resolved by
|
|
6680
|
+
// its start line when the commit re-rendered it, or the paragraph a
|
|
6681
|
+
// transient with text became. null = the commit failed, do nothing more.
|
|
6682
|
+
async function settleEnterSource(burst, blockEl) {
|
|
6683
|
+
if (burst.transient) {
|
|
6684
|
+
transientLandedLine = null;
|
|
6685
|
+
if (!(await switchAwayFrom())) return null;
|
|
6686
|
+
return transientLandedLine === null ? null : blockElAtLine(transientLandedLine);
|
|
6687
|
+
}
|
|
6688
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
6689
|
+
if (!rec) return null;
|
|
6690
|
+
const startLine = rec.startLine;
|
|
6691
|
+
if (!(await switchAwayFrom())) return null;
|
|
6692
|
+
return blockEl.isConnected ? blockEl : blockElAtLine(startLine);
|
|
6693
|
+
}
|
|
6694
|
+
|
|
6695
|
+
// The split of a real paragraph / heading. Up to two ops in one render: the
|
|
6696
|
+
// pending typing over the unsplit block (D4), then commitSplit(). A failed
|
|
6697
|
+
// render rolls both back and leaves the screen split; pendingStructure
|
|
6698
|
+
// re-commits it from the live DOM on the next resolution (D7).
|
|
6699
|
+
async function splitBlockAtCaret(burst, editEl, blockEl) {
|
|
6700
|
+
const blockId = burst.blockId;
|
|
6701
|
+
const rec = blocks.find((b) => b.id === blockId);
|
|
6702
|
+
if (!rec) return;
|
|
6703
|
+
const typed = typedInBurst(burst);
|
|
6704
|
+
let right = null;
|
|
6705
|
+
// R5: the right half is armed and focused first, while the burst is still
|
|
6706
|
+
// current (so the focusin that raises opens nothing), then the burst ends.
|
|
6707
|
+
withFocusoutSuppressed(() => {
|
|
6708
|
+
right = splitSurfaceAtCaret(editEl);
|
|
6709
|
+
if (right) armProvisionalSurface(right);
|
|
6710
|
+
});
|
|
6711
|
+
if (!right) return;
|
|
6712
|
+
endBurstWithoutResolve();
|
|
6713
|
+
const leftText = surfaceCommitText(editEl, burst.blockType, burst.depth);
|
|
6714
|
+
const rightText = inlineMd.escapeBlockStarts(inlineMd.serializeInline(right).md);
|
|
6715
|
+
// commitSplit() starts the right half here whatever the typing op did:
|
|
6716
|
+
// that op rewrites only this block, so its start line does not move.
|
|
6717
|
+
const rightStartLine = rec.startLine + leftText.split('\n').length + 1;
|
|
6718
|
+
const ok = await commitTypingThenStructure({ typed,
|
|
6719
|
+
commit: (state) => commitSplit(state, blockId, leftText, rightText),
|
|
6720
|
+
oldRange: [rec.startLine, rec.endLine],
|
|
6721
|
+
lineMap: { ids: { [blockId]: rec.startLine }, provisional: rightStartLine },
|
|
6722
|
+
pending: { surfaces: [editEl, right], serialize: () => {
|
|
6723
|
+
const left = surfaceCommitText(editEl, burst.blockType, burst.depth);
|
|
6724
|
+
const text = left + '\n\n' + inlineMd.escapeBlockStarts(inlineMd.serializeInline(right).md);
|
|
6725
|
+
return { text, lineMap: { ids: { [blockId]: rec.startLine },
|
|
6726
|
+
provisional: rec.startLine + left.split('\n').length + 1 } };
|
|
6727
|
+
} } });
|
|
6728
|
+
if (ok) focusBlockAtLine(rightStartLine, false);
|
|
6729
|
+
}
|
|
6730
|
+
|
|
6731
|
+
// Enter in the middle of a transient with text: its two halves are its
|
|
6732
|
+
// commit — one op (commitLinesAtTransient()), caret to the right one's
|
|
6733
|
+
// start. Nothing is drawn first; a failed commit leaves the transient as it
|
|
6734
|
+
// was, its text and caret included, and keys held meanwhile replay there.
|
|
6735
|
+
async function splitTransientAtCaret(blockEl, editEl) {
|
|
6736
|
+
const h = halvesAtCaret(editEl);
|
|
6737
|
+
const left = inlineMd.escapeBlockStarts(inlineMd.serializeInline(h.left).md).split('\n');
|
|
6738
|
+
const right = inlineMd.escapeBlockStarts(inlineMd.serializeInline(h.right).md).split('\n');
|
|
6739
|
+
// The burst ends first, as commitListStructure()'s does: the render
|
|
6740
|
+
// detaches the focused transient, and Chromium's synchronous focusout
|
|
6741
|
+
// would otherwise resolve this burst a second time — measured: the
|
|
6742
|
+
// transient's whole text committed again above the two halves.
|
|
6743
|
+
endBurstWithoutResolve();
|
|
6744
|
+
const res = await commitLinesAtTransient(blockEl, left.concat([''], right));
|
|
6745
|
+
if (res.ok) {
|
|
6746
|
+
focusBlockAtLine(res.newStartLine + left.length + 1, false);
|
|
6747
|
+
return;
|
|
6748
|
+
}
|
|
6749
|
+
// Failed: the transient keeps its text, so it gets its burst back (its
|
|
6750
|
+
// baseline is empty, so the next resolution commits that text).
|
|
6751
|
+
if (editEl.isConnected && !currentBurst) startBurst(editEl);
|
|
6752
|
+
}
|
|
6753
|
+
|
|
6754
|
+
// ── D7: a structure whose render failed ──────────────────────────────────
|
|
6755
|
+
// The screen keeps what the user made; `lines` was rolled back. The next
|
|
6756
|
+
// resolution (resolveOpenSession(), first), a focus into any surface, or a
|
|
6757
|
+
// structural key (R6) re-commits it from the live DOM before anything else
|
|
6758
|
+
// can commit over it: a commit elsewhere would render, and a render drops
|
|
6759
|
+
// the screen's half of it. Task 2 introduces the slot for the paragraph
|
|
6760
|
+
// split; later tasks reuse it.
|
|
6761
|
+
// { range: {startLine, endLine}, before, surfaces, serialize }
|
|
6762
|
+
// `serialize()` → { text, lineMap }: what to write over `range`, and the
|
|
6763
|
+
// pendingFlightLineMap that re-points held keys at the result.
|
|
6764
|
+
const PENDING_STRUCTURE_LOST_MESSAGE =
|
|
6765
|
+
'先前沒有寫進檔案的分段/合併已無法補寫:文件在那之後被改過。畫面已換回檔案的內容。';
|
|
6766
|
+
// `covers(surface)` (optional): true when the retry's serialize() reads that
|
|
6767
|
+
// surface — a list run's retry reads every item of the run, not only the
|
|
6768
|
+
// ones the gesture touched. Without it, `surfaces` is the answer.
|
|
6769
|
+
// `typedIds` (fix round 1, I2): ids of covered blocks whose burst a retry
|
|
6770
|
+
// ended with typed text in it. Their text is then only in the DOM, so a
|
|
6771
|
+
// later serialize() must read them from there, not replay their source.
|
|
6772
|
+
function setPendingStructure(startLine, endLine, surfaces, serialize, covers) {
|
|
6773
|
+
pendingStructure = { range: { startLine, endLine },
|
|
6774
|
+
before: lines.slice(startLine - 1, endLine).join('\n'), surfaces, serialize, covers: covers || null,
|
|
6775
|
+
typedIds: new Set() };
|
|
6776
|
+
setDirty(); // R34: it is unsaved work (documentIsDirty())
|
|
6777
|
+
}
|
|
6778
|
+
|
|
6779
|
+
// Re-commits pendingStructure. true = nothing pending (any more); false =
|
|
6780
|
+
// its render failed again (banner shown, the slot stays). A surface the
|
|
6781
|
+
// retry serialized loses its burst without resolving: its text is in the
|
|
6782
|
+
// retry. A slot whose surfaces a render already replaced, or whose range
|
|
6783
|
+
// `lines` no longer holds, is dropped — there is nothing left to write it
|
|
6784
|
+
// from, or nowhere safe to write it.
|
|
6785
|
+
//
|
|
6786
|
+
// v3.14.0 T6: a caret that was in a surface the retry serialized is put
|
|
6787
|
+
// back on the same line and offset once it lands (a Ctrl+S right after a
|
|
6788
|
+
// failed list Enter used to leave focus on the body). And (R28) an open
|
|
6789
|
+
// burst with typed text on a surface the retry does NOT serialize — typing
|
|
6790
|
+
// done after a retry failed too, in another block — is resolved INSIDE the
|
|
6791
|
+
// retry: the structure op first (it happened first), then that burst's own
|
|
6792
|
+
// commit, which renders both. Before, the retry rendered first and dropped
|
|
6793
|
+
// that burst's surface, and its text was lost.
|
|
6794
|
+
async function retryPendingStructure() {
|
|
6795
|
+
const ps = pendingStructure;
|
|
6796
|
+
if (!ps) return true;
|
|
6797
|
+
if (!ps.surfaces.every((s) => s.isConnected) ||
|
|
6798
|
+
lines.slice(ps.range.startLine - 1, ps.range.endLine).join('\n') !== ps.before) {
|
|
6799
|
+
// R32: never silently. Something wrote `lines` or rendered over the
|
|
6800
|
+
// structure first (the R32 audit found no gesture that can); what the
|
|
6801
|
+
// screen showed of it cannot be written any more, so say so and show
|
|
6802
|
+
// the file as it is.
|
|
6803
|
+
pendingStructure = null;
|
|
6804
|
+
setDirty();
|
|
6805
|
+
showError(PENDING_STRUCTURE_LOST_MESSAGE, null, null);
|
|
6806
|
+
if (ps.surfaces.every((s) => s.isConnected)) await safeRerenderAll();
|
|
6807
|
+
return true;
|
|
6808
|
+
}
|
|
6809
|
+
const covers = (el) => !!el && (ps.covers ? ps.covers(el) : ps.surfaces.indexOf(el) !== -1);
|
|
6810
|
+
const caret = selectionSnapshotIn(activeFlightSurface());
|
|
6811
|
+
const caretTag = caret && caret.anchor !== null && covers(caret.surface) ? flightTagOf(caret) : null;
|
|
6812
|
+
// serialize() before the burst ends: a list run's bystander replay reads
|
|
6813
|
+
// the open burst to leave a typed-into item to the DOM.
|
|
6814
|
+
const s = ps.serialize(ps);
|
|
6815
|
+
if (currentBurst && covers(currentBurst.editEl)) {
|
|
6816
|
+
// I2: if this retry's render fails, that typing lives only in the DOM.
|
|
6817
|
+
if (burstHasUncommittedEdit() && currentBurst.blockId !== null) ps.typedIds.add(String(currentBurst.blockId));
|
|
6818
|
+
endBurstWithoutResolve();
|
|
6819
|
+
}
|
|
6820
|
+
const other = burstHasUncommittedEdit() ? currentBurst : null;
|
|
6821
|
+
// `text: null` is a removal (an emptied block whose removal did not land).
|
|
6822
|
+
const result = s.text === null
|
|
6823
|
+
? commitRangeRemoval({ lines, blocks, stack }, ps.range.startLine, ps.range.endLine)
|
|
6824
|
+
: commitRangeEdit({ lines, blocks, stack }, ps.range.startLine, ps.range.endLine, s.text);
|
|
6825
|
+
const prevLines = lines;
|
|
6826
|
+
const prevBlocks = blocks;
|
|
6827
|
+
if (result.op !== null) lines = result.lines;
|
|
6828
|
+
const editRange = { startLine: ps.range.startLine, endLine: ps.range.endLine,
|
|
6829
|
+
delta: result.op ? result.op.after.length - result.op.before.length : 0 };
|
|
6830
|
+
const undoStructure = () => {
|
|
6831
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6832
|
+
blocks = prevBlocks;
|
|
6833
|
+
};
|
|
6834
|
+
let ok;
|
|
6835
|
+
if (other) {
|
|
6836
|
+
// R28. `blocks` shifted with the structure op, so the burst's commit
|
|
6837
|
+
// finds its block where the structure moved it. A render rebinds
|
|
6838
|
+
// `blocks`, which is how this tells whether one landed: a refusal can
|
|
6839
|
+
// render and still answer false, and a no-op answers true unrendered.
|
|
6840
|
+
const shifted = result.op !== null ? result.blocks : blocks;
|
|
6841
|
+
blocks = shifted;
|
|
6842
|
+
pendingFlightLineMap = s.lineMap;
|
|
6843
|
+
const resolved = await resolveBurst();
|
|
6844
|
+
if (blocks !== shifted) {
|
|
6845
|
+
if (pendingStructure === ps) { pendingStructure = null; setDirty(); }
|
|
6846
|
+
return resolved;
|
|
6847
|
+
}
|
|
6848
|
+
if (!resolved) { undoStructure(); return false; }
|
|
6849
|
+
pendingFlightLineMap = s.lineMap;
|
|
6850
|
+
ok = await safeRerenderAll(editRange);
|
|
6851
|
+
} else {
|
|
6852
|
+
pendingFlightLineMap = s.lineMap;
|
|
6853
|
+
ok = await safeRerenderAll(editRange);
|
|
6854
|
+
}
|
|
6855
|
+
if (!ok) { undoStructure(); return false; }
|
|
6856
|
+
if (pendingStructure === ps) { pendingStructure = null; setDirty(); }
|
|
6857
|
+
if (caretTag && !activeFlightSurface() && s.lineMap) {
|
|
6858
|
+
const line = caretTag.blockId === null ? s.lineMap.provisional : s.lineMap.ids[caretTag.blockId];
|
|
6859
|
+
const el = line === null || line === undefined ? null : blockElAtLine(line);
|
|
6860
|
+
const surface = el ? blockContentEl(el) : null;
|
|
6861
|
+
if (surface && surface.classList.contains('ed-wys-armed')) {
|
|
6862
|
+
surface.focus();
|
|
6863
|
+
restoreSelection(surface, caretTag.anchor, caretTag.focus);
|
|
6864
|
+
}
|
|
6865
|
+
}
|
|
6866
|
+
return true;
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
// R6: a structural key pressed while a structure is pending. The retry goes
|
|
6870
|
+
// first and the key is held behind it (the retry's render is a flight), so
|
|
6871
|
+
// it — and anything typed after it — replays once the retry has landed, in
|
|
6872
|
+
// order. If the retry fails the key is dropped: the error card says why.
|
|
6873
|
+
function retryStructureThenReplay(e, surface) {
|
|
6874
|
+
e.preventDefault();
|
|
6875
|
+
const entry = { kind: 'key', key: e.key, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey,
|
|
6876
|
+
altKey: e.altKey, metaKey: e.metaKey };
|
|
6877
|
+
const retry = retryPendingStructure();
|
|
6878
|
+
holdFlightEntry(surface, entry);
|
|
6879
|
+
const drop = () => {
|
|
6880
|
+
const i = flightBuffer.indexOf(entry);
|
|
6881
|
+
if (i !== -1) flightBuffer.splice(i, 1);
|
|
6882
|
+
};
|
|
6883
|
+
retry.then((ok) => {
|
|
6884
|
+
if (!ok) drop();
|
|
6885
|
+
scheduleFlightFlush();
|
|
6886
|
+
}, (err) => { drop(); structuralKeyFailed(err); });
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6889
|
+
// R32 (fix round 1): a structural key's async work is fire-and-forget from
|
|
6890
|
+
// its keydown. A throw there must not vanish (an unhandled rejection) nor
|
|
6891
|
+
// leave held keys waiting for a flush nobody schedules — flightActive()
|
|
6892
|
+
// would then hold every later key. The flight count itself cannot stick:
|
|
6893
|
+
// safeRerenderAll() ends its flight in `finally`.
|
|
6894
|
+
function structuralKeyFailed(err) {
|
|
6895
|
+
if (typeof console !== 'undefined' && console.error) console.error('md2doc: a structural key failed', err);
|
|
6896
|
+
showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(err) + ')。文件維持修改前的樣子。', null, null);
|
|
6897
|
+
scheduleFlightFlush();
|
|
6898
|
+
}
|
|
6899
|
+
function isStructuralKeyForRetry(e, surface) {
|
|
6900
|
+
if (e.isComposing || e.keyCode === 229 || e.ctrlKey || e.metaKey || e.altKey) return false;
|
|
6901
|
+
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') return true;
|
|
6902
|
+
// v3.14.0 T3: Backspace at a start / Delete at an end joins blocks.
|
|
6903
|
+
if (e.shiftKey || (e.key !== 'Backspace' && e.key !== 'Delete')) return false;
|
|
6904
|
+
const place = caretPlace(surface, true);
|
|
6905
|
+
return place === 'empty' || place === (e.key === 'Backspace' ? 'start' : 'end');
|
|
6906
|
+
}
|
|
6907
|
+
|
|
6908
|
+
// ── v3.14.0 T3: Backspace at a block start, Delete at a block end ──────
|
|
6909
|
+
//
|
|
6910
|
+
// Spec §3.2. Backspace with the caret at the start of a paragraph joins it
|
|
6911
|
+
// to the block above (a paragraph, a heading, or a list item's text); at
|
|
6912
|
+
// the start of a heading it makes the heading a paragraph. Delete at the
|
|
6913
|
+
// end of a paragraph or heading pulls the next one up. A neighbour that has
|
|
6914
|
+
// no text to join (a table, a code block, a list item for Delete) is
|
|
6915
|
+
// selected instead, and an EMPTY block below such a neighbour goes away
|
|
6916
|
+
// with the caret landing at the neighbour's end (R12). Every case is one
|
|
6917
|
+
// undo step; typing the key commits first is its own earlier step (D4).
|
|
6918
|
+
// The join is done on screen synchronously, the caret placed at the seam,
|
|
6919
|
+
// then committed through the flight guard (R5: the new caret first, then
|
|
6920
|
+
// the burst ends), and a failed render keeps the screen as joined
|
|
6921
|
+
// (pendingStructure, D7).
|
|
6922
|
+
|
|
6923
|
+
// Inline marks that merge across a seam when they meet: same tag, no
|
|
6924
|
+
// attributes. Two links merge only when their href is the same.
|
|
6925
|
+
const SEAM_MERGE_TAGS = ['STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'U', 'CODE'];
|
|
6926
|
+
function sameSeamMark(a, b) {
|
|
6927
|
+
if (!a || !b || a.nodeType !== 1 || b.nodeType !== 1 || a.nodeName !== b.nodeName) return false;
|
|
6928
|
+
if (a.nodeName === 'A') return a.getAttribute('href') === b.getAttribute('href');
|
|
6929
|
+
return SEAM_MERGE_TAGS.indexOf(a.nodeName) !== -1 && !a.attributes.length && !b.attributes.length;
|
|
6930
|
+
}
|
|
6931
|
+
|
|
6932
|
+
// Appends `rightNodes` to `leftSurface` and returns the seam: the left
|
|
6933
|
+
// text length before the join. A trailing <br> on the left goes first (at
|
|
6934
|
+
// the end of a line it showed nothing; before joined text it would be a
|
|
6935
|
+
// hard break), and a right side that holds nothing is not appended at all
|
|
6936
|
+
// (an emptied surface keeps a placeholder <br>). At the seam, marks of the
|
|
6937
|
+
// same kind merge, repeated inward, so `**bo**` + `**ld**` is `**bold**`
|
|
6938
|
+
// and not `**bo****ld**`.
|
|
6939
|
+
//
|
|
6940
|
+
// `flatten` (R30, fix round 1 C3): the target is a heading, which is one
|
|
6941
|
+
// line — every hard break (<br>) and soft-wrap newline in the joined text
|
|
6942
|
+
// becomes one space. Measured before: `first\` + `second` merged into
|
|
6943
|
+
// `# D` wrote `# Dfirst\` and a stray `second` paragraph.
|
|
6944
|
+
function joinSeam(leftSurface, rightNodes, flatten) {
|
|
6945
|
+
// T8: a Shift+Enter tail <br> holds no line of its own (insertBrAtCaret()).
|
|
6946
|
+
while (leftSurface.lastChild && leftSurface.lastChild.nodeName === 'BR' &&
|
|
6947
|
+
leftSurface.lastChild.getAttribute('data-ed-tail') === '1') leftSurface.removeChild(leftSurface.lastChild);
|
|
6948
|
+
const tail = leftSurface.lastChild;
|
|
6949
|
+
if (tail && tail.nodeName === 'BR') leftSurface.removeChild(tail);
|
|
6950
|
+
leftSurface.normalize();
|
|
6951
|
+
const seam = leftSurface.textContent.length;
|
|
6952
|
+
const holder = document.createElement('p');
|
|
6953
|
+
rightNodes.forEach((n) => holder.appendChild(n));
|
|
6954
|
+
// Asked BEFORE the flatten below, which turns an emptied surface's
|
|
6955
|
+
// placeholder <br> into a space. Fix round 2: asked after it, that space
|
|
6956
|
+
// was appended to the heading — Backspace removing an emptied paragraph
|
|
6957
|
+
// under `# D` saved `# D `.
|
|
6958
|
+
if (holder.textContent === '' && !holder.querySelector('img')) return seam;
|
|
6959
|
+
if (flatten) {
|
|
6960
|
+
// A break at either edge of the joined text separates nothing, so the
|
|
6961
|
+
// space it would become goes too (fix round 2: a text ending in a
|
|
6962
|
+
// <br> joined a heading as `# Dfirst `).
|
|
6963
|
+
const breakAt = (atEnd) => {
|
|
6964
|
+
const leaf = edgeLeaf(holder, atEnd);
|
|
6965
|
+
if (!leaf) return false;
|
|
6966
|
+
if (leaf.nodeName === 'BR') return true;
|
|
6967
|
+
return leaf.nodeType === 3 && (atEnd ? /\n[ \t]*$/ : /^[ \t]*\n/).test(leaf.data);
|
|
6968
|
+
};
|
|
6969
|
+
// normalize() first: an editing surface often carries an EMPTY text
|
|
6970
|
+
// node after its last <br> (invisible in innerHTML), and edgeLeaf()
|
|
6971
|
+
// would answer that instead of the break.
|
|
6972
|
+
holder.normalize();
|
|
6973
|
+
const leadingBreak = breakAt(false);
|
|
6974
|
+
const trailingBreak = breakAt(true);
|
|
6975
|
+
Array.prototype.slice.call(holder.querySelectorAll('br')).forEach((br) => {
|
|
6976
|
+
br.parentNode.replaceChild(document.createTextNode('\n'), br);
|
|
6977
|
+
});
|
|
6978
|
+
holder.normalize();
|
|
6979
|
+
const walker = document.createTreeWalker(holder, NodeFilter.SHOW_TEXT);
|
|
6980
|
+
for (let t = walker.nextNode(); t; t = walker.nextNode()) {
|
|
6981
|
+
t.data = t.data.replace(/[ \t]*\n[ \t\n]*/g, ' ');
|
|
6982
|
+
}
|
|
6983
|
+
const first = edgeLeaf(holder, false);
|
|
6984
|
+
if (leadingBreak && first && first.nodeType === 3) first.data = first.data.replace(/^ /, '');
|
|
6985
|
+
const last = edgeLeaf(holder, true);
|
|
6986
|
+
if (trailingBreak && last && last.nodeType === 3) last.data = last.data.replace(/ $/, '');
|
|
6987
|
+
if (holder.textContent === '' && !holder.querySelector('img')) return seam;
|
|
6988
|
+
}
|
|
6989
|
+
let a = leftSurface.lastChild;
|
|
6990
|
+
while (holder.firstChild) leftSurface.appendChild(holder.firstChild);
|
|
6991
|
+
while (a && sameSeamMark(a, a.nextSibling)) {
|
|
6992
|
+
const b = a.nextSibling;
|
|
6993
|
+
const inner = a.lastChild;
|
|
6994
|
+
while (b.firstChild) a.appendChild(b.firstChild);
|
|
6995
|
+
b.parentNode.removeChild(b);
|
|
6996
|
+
a = inner;
|
|
6997
|
+
}
|
|
6998
|
+
leftSurface.normalize();
|
|
6999
|
+
return seam;
|
|
7000
|
+
}
|
|
7001
|
+
|
|
7002
|
+
// focusBlockAtLine() with the caret at a character offset.
|
|
7003
|
+
function focusBlockAtLineOffset(startLine, offset) {
|
|
7004
|
+
const el = blockElAtLine(startLine);
|
|
7005
|
+
const surface = el ? blockContentEl(el) : null;
|
|
7006
|
+
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
7007
|
+
surface.focus();
|
|
7008
|
+
restoreSelection(surface, offset, offset);
|
|
7009
|
+
}
|
|
7010
|
+
|
|
7011
|
+
// The armed text surface a join can go into: a paragraph, a heading or a
|
|
7012
|
+
// list item, or null.
|
|
7013
|
+
function mergeSurfaceOf(blockEl) {
|
|
7014
|
+
if (!blockEl) return null;
|
|
7015
|
+
const t = blockEl.getAttribute('data-block-type');
|
|
7016
|
+
if (t !== 'paragraph' && t !== 'heading' && t !== 'li') return null;
|
|
7017
|
+
const s = blockContentEl(blockEl);
|
|
7018
|
+
return s && s.classList.contains('ed-wys-armed') ? s : null;
|
|
7019
|
+
}
|
|
7020
|
+
function nextBlockEl(el) {
|
|
7021
|
+
let n = el.nextElementSibling;
|
|
7022
|
+
while (n && !n.classList.contains('ed-block')) n = n.nextElementSibling;
|
|
7023
|
+
return n;
|
|
7024
|
+
}
|
|
7025
|
+
function selectBlock(blockEl) {
|
|
7026
|
+
const rec = blockRecOf(blockEl);
|
|
7027
|
+
if (!rec || rec.endLine < rec.startLine) return;
|
|
7028
|
+
setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
|
|
7029
|
+
}
|
|
7030
|
+
function caretToSurface(surface, atEnd) {
|
|
7031
|
+
surface.focus();
|
|
7032
|
+
const r = document.createRange();
|
|
7033
|
+
r.selectNodeContents(surface);
|
|
7034
|
+
r.collapse(!atEnd);
|
|
7035
|
+
const sel = window.getSelection();
|
|
7036
|
+
sel.removeAllRanges();
|
|
7037
|
+
sel.addRange(r);
|
|
7038
|
+
}
|
|
7039
|
+
|
|
7040
|
+
// Plain Backspace (dir -1) at the start, or plain Delete (dir 1) at the end,
|
|
7041
|
+
// of a paragraph, heading or transient surface. true = handled (and
|
|
7042
|
+
// preventDefault() done); false = the browser's own key.
|
|
7043
|
+
function blockEdgeDelete(e, editEl, dir) {
|
|
7044
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
|
|
7045
|
+
const burst = currentBurst;
|
|
7046
|
+
if (!burst || burst.editEl !== editEl) return false;
|
|
7047
|
+
if (burst.blockType !== 'paragraph' && burst.blockType !== 'heading') return false;
|
|
7048
|
+
const place = caretPlace(editEl, true);
|
|
7049
|
+
if (dir < 0 ? (place !== 'start' && place !== 'empty') : (place !== 'end' && place !== 'empty')) return false;
|
|
7050
|
+
if (inlineMd.serializeInline(editEl).unsupported.length) return false;
|
|
7051
|
+
e.preventDefault();
|
|
7052
|
+
const blockEl = editEl.closest('.ed-block');
|
|
7053
|
+
(dir < 0 ? backspaceAtStart(burst, editEl, blockEl, place === 'empty')
|
|
7054
|
+
: deleteAtEnd(burst, editEl, blockEl, place === 'empty')).catch(structuralKeyFailed);
|
|
7055
|
+
return true;
|
|
7056
|
+
}
|
|
7057
|
+
|
|
7058
|
+
// The commit every structural key here ends with (the Enter split, every
|
|
7059
|
+
// Backspace / Delete join, the heading -> paragraph conversion, the
|
|
7060
|
+
// removal of an emptied block): the pending typing of the block the key
|
|
7061
|
+
// was pressed in (D4), then the structure — `o.commit(state)`, or `o.text`
|
|
7062
|
+
// over the range `o.range(blocks)` computes in the state after the typing
|
|
7063
|
+
// op (a removal when `text` is null) — in ONE render. `oldRange` is the
|
|
7064
|
+
// range in today's lines. A failed render rolls both ops back, newest
|
|
7065
|
+
// first, and records `o.pending` (D7). Fix round 1 (I2): the split used to
|
|
7066
|
+
// carry its own copy of all of this.
|
|
7067
|
+
async function commitTypingThenStructure(o) {
|
|
7068
|
+
const prevLines = lines;
|
|
7069
|
+
const first = o.typed ? commitEdit({ lines, blocks, stack }, o.typed.blockId, o.typed.text) : null;
|
|
7070
|
+
const typedOp = first && first.op ? first : null;
|
|
7071
|
+
const state = typedOp ? { lines: typedOp.lines, blocks: typedOp.blocks, stack } : { lines, blocks, stack };
|
|
7072
|
+
let second;
|
|
7073
|
+
if (o.commit) second = o.commit(state);
|
|
7074
|
+
else {
|
|
7075
|
+
const range = o.range(state.blocks);
|
|
7076
|
+
second = o.text === null
|
|
7077
|
+
? commitRangeRemoval(state, range[0], range[1])
|
|
7078
|
+
: commitRangeEdit(state, range[0], range[1], o.text);
|
|
7079
|
+
}
|
|
7080
|
+
lines = second.lines;
|
|
7081
|
+
const delta = (typedOp ? typedOp.op.after.length - typedOp.op.before.length : 0) +
|
|
7082
|
+
(second.op ? second.op.after.length - second.op.before.length : 0);
|
|
7083
|
+
pendingFlightLineMap = o.lineMap;
|
|
7084
|
+
const ok = await safeRerenderAll({ startLine: o.oldRange[0], endLine: o.oldRange[1], delta });
|
|
7085
|
+
if (ok) return true;
|
|
7086
|
+
lines = rollbackFailedRender({ lines, stack }, second, typedOp ? typedOp.lines : prevLines);
|
|
7087
|
+
if (typedOp) lines = rollbackFailedRender({ lines, stack }, typedOp, prevLines);
|
|
7088
|
+
if (o.pending) setPendingStructure(o.oldRange[0], o.oldRange[1], o.pending.surfaces, o.pending.serialize);
|
|
7089
|
+
return false;
|
|
7090
|
+
}
|
|
7091
|
+
|
|
7092
|
+
function typedInBurst(burst) {
|
|
7093
|
+
if (burst.transient || burstBaselineHtml(burst.editEl) === burst.original) return null;
|
|
7094
|
+
burst.history.flushTyping();
|
|
7095
|
+
return { blockId: burst.blockId, text: burstCommitText(burst) };
|
|
7096
|
+
}
|
|
7097
|
+
|
|
7098
|
+
async function backspaceAtStart(burst, editEl, blockEl, empty) {
|
|
7099
|
+
if (burst.blockType === 'heading') { await headingToParagraph(burst, editEl, blockEl, empty); return; }
|
|
7100
|
+
const prev = previousBlockEl(blockEl);
|
|
7101
|
+
if (!prev) return;
|
|
7102
|
+
// An empty transient above (Enter at this block's start made it): the
|
|
7103
|
+
// Backspace takes it away. One with text: this block joins it (R31).
|
|
7104
|
+
if (isTransient(prev)) {
|
|
7105
|
+
if (transientSurfaceEmpty(prev)) removeTransient();
|
|
7106
|
+
else if (!burst.transient) await joinTransientIntoBlock(burst, editEl, prev, false);
|
|
7107
|
+
return;
|
|
7108
|
+
}
|
|
7109
|
+
const curIsTransient = !!burst.transient;
|
|
7110
|
+
const prevSurface = mergeSurfaceOf(prev);
|
|
7111
|
+
if (!prevSurface) {
|
|
7112
|
+
if (!empty) { selectBlock(prev); return; }
|
|
7113
|
+
// R12 (spec §3.2): an empty block below a table / code block goes, and
|
|
7114
|
+
// the caret lands at that block's end.
|
|
7115
|
+
if (curIsTransient) {
|
|
7116
|
+
removeTransient();
|
|
7117
|
+
arrowLandOn(prev, -1, null);
|
|
7118
|
+
return;
|
|
7119
|
+
}
|
|
7120
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
7121
|
+
const prevRec = blockRecOf(prev);
|
|
7122
|
+
if (!rec || !prevRec) return;
|
|
7123
|
+
endBurstWithoutResolve();
|
|
7124
|
+
const ok = await commitTypingThenStructure({ typed: null, text: null,
|
|
7125
|
+
range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
|
|
7126
|
+
oldRange: [rec.startLine, rec.endLine], lineMap: { ids: {}, provisional: null },
|
|
7127
|
+
pending: { surfaces: [editEl], serialize: () => ({ text: null, lineMap: { ids: {}, provisional: null } }) } });
|
|
7128
|
+
if (ok) {
|
|
7129
|
+
const landed = blockElAtLine(prevRec.startLine);
|
|
7130
|
+
if (landed) arrowLandOn(landed, -1, null);
|
|
7131
|
+
}
|
|
7132
|
+
return;
|
|
7133
|
+
}
|
|
7134
|
+
if (curIsTransient && empty) {
|
|
7135
|
+
removeTransient();
|
|
7136
|
+
caretToSurface(prevSurface, true);
|
|
7137
|
+
return;
|
|
7138
|
+
}
|
|
7139
|
+
await joinIntoPrevious(burst, editEl, blockEl, prev, prevSurface, empty);
|
|
7140
|
+
}
|
|
7141
|
+
|
|
7142
|
+
// Backspace at the start of `blockEl` (a paragraph or a transient with
|
|
7143
|
+
// text) into the block above: a paragraph, a heading or a list item.
|
|
7144
|
+
// `empty`: the surface was emptied, so its typing is not committed as an op
|
|
7145
|
+
// of its own (final review I2) — it would write the placeholder <br> left
|
|
7146
|
+
// behind as a raw-html block, and one Ctrl+Z would land there. The join
|
|
7147
|
+
// alone is the step, as for every other emptied-block removal (C1, R12).
|
|
7148
|
+
async function joinIntoPrevious(burst, editEl, blockEl, prev, prevSurface, empty) {
|
|
7149
|
+
const curIsTransient = !!burst.transient;
|
|
7150
|
+
const prevRec = blockRecOf(prev);
|
|
7151
|
+
const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
|
|
7152
|
+
if (!prevRec || (!curIsTransient && !curRec)) return;
|
|
7153
|
+
const prevType = prev.getAttribute('data-block-type');
|
|
7154
|
+
const prevDepth = blockDepthOf(prevType, prevSurface);
|
|
7155
|
+
let run = null;
|
|
7156
|
+
let runRange = null;
|
|
7157
|
+
if (prevType === 'li') {
|
|
7158
|
+
run = listRunOf(prev);
|
|
7159
|
+
runRange = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
|
|
7160
|
+
if (!runRange) return;
|
|
7161
|
+
if (!listRunSupportsStructuralEdit(run, prev)) { refuseStructuralListEdit(); return; }
|
|
7162
|
+
}
|
|
7163
|
+
const typed = empty ? null : typedInBurst(burst);
|
|
7164
|
+
let seam = 0;
|
|
7165
|
+
withFocusoutSuppressed(() => {
|
|
7166
|
+
seam = joinSeam(prevSurface, Array.prototype.slice.call(editEl.childNodes), prevType === 'heading');
|
|
7167
|
+
prevSurface.focus();
|
|
7168
|
+
restoreSelection(prevSurface, seam, seam);
|
|
7169
|
+
if (curIsTransient) removeTransient();
|
|
7170
|
+
else blockEl.parentNode.removeChild(blockEl);
|
|
7171
|
+
});
|
|
7172
|
+
endBurstWithoutResolve();
|
|
7173
|
+
const startLine = run ? runRange.startLine : prevRec.startLine;
|
|
7174
|
+
const curId = curRec ? curRec.id : null;
|
|
7175
|
+
const endIn = (bs, fallback) => {
|
|
7176
|
+
if (curId === null) return fallback;
|
|
7177
|
+
const r = bs.find((b) => b.id === curId);
|
|
7178
|
+
return r ? r.endLine : fallback;
|
|
7179
|
+
};
|
|
7180
|
+
const serialize = () => {
|
|
7181
|
+
if (run) {
|
|
7182
|
+
const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, prev) });
|
|
7183
|
+
const map = flightLineMapOf(out.lineMeta, runRange.startLine);
|
|
7184
|
+
return { text: out.md, lineMap: { ids: map.ids, provisional: null } };
|
|
7185
|
+
}
|
|
7186
|
+
return { text: surfaceCommitText(prevSurface, prevType, prevDepth),
|
|
7187
|
+
lineMap: { ids: { [prevRec.id]: prevRec.startLine }, provisional: null } };
|
|
7188
|
+
};
|
|
7189
|
+
const s = serialize();
|
|
7190
|
+
const oldEnd = curRec ? curRec.endLine : (run ? runRange.endLine : prevRec.endLine);
|
|
7191
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text,
|
|
7192
|
+
range: (bs) => [startLine, endIn(bs, run ? runRange.endLine : prevRec.endLine)],
|
|
7193
|
+
oldRange: [startLine, oldEnd], lineMap: s.lineMap,
|
|
7194
|
+
pending: { surfaces: [prevSurface], serialize } });
|
|
7195
|
+
if (ok) focusBlockAtLineOffset(prevRec.startLine, seam);
|
|
7196
|
+
}
|
|
7197
|
+
|
|
7198
|
+
// Backspace at the start of a heading: it becomes a paragraph (its text
|
|
7199
|
+
// escaped where it would read as a block marker). An empty heading is
|
|
7200
|
+
// removed and an empty transient paragraph takes its place.
|
|
7201
|
+
async function headingToParagraph(burst, editEl, blockEl, empty) {
|
|
7202
|
+
if (empty) { await replaceEmptiedBlockWithTransient(burst, editEl); return; }
|
|
7203
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
7204
|
+
if (!rec) return;
|
|
7205
|
+
const typed = typedInBurst(burst);
|
|
7206
|
+
endBurstWithoutResolve();
|
|
7207
|
+
const text = () => inlineMd.escapeBlockStarts(inlineMd.serializeInline(editEl).md);
|
|
7208
|
+
const lineMap = { ids: { [rec.id]: rec.startLine }, provisional: null };
|
|
7209
|
+
const ok = await commitTypingThenStructure({ typed, text: text(),
|
|
7210
|
+
range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
|
|
7211
|
+
oldRange: [rec.startLine, rec.endLine], lineMap,
|
|
7212
|
+
pending: { surfaces: [editEl], serialize: () => ({ text: text(), lineMap }) } });
|
|
7213
|
+
if (ok) focusBlockAtLineOffset(rec.startLine, 0);
|
|
7214
|
+
}
|
|
7215
|
+
|
|
7216
|
+
// An emptied real block (paragraph or heading) under Enter, or an empty
|
|
7217
|
+
// heading under Backspace: ONE removal op (an empty block cannot be
|
|
7218
|
+
// written), then a focused transient where it was — after the block before
|
|
7219
|
+
// it (the end of that block's subtree for a li), or at the top. Keys typed
|
|
7220
|
+
// during the render are held and replay into that transient. As with the
|
|
7221
|
+
// empty-li Enter (T1), Ctrl+Z in that still-empty transient first takes the
|
|
7222
|
+
// transient away, then the next one reverts the removal.
|
|
7223
|
+
async function replaceEmptiedBlockWithTransient(burst, editEl) {
|
|
7224
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
7225
|
+
if (!rec) return;
|
|
7226
|
+
const preceding = blocks.filter((b) => b.id !== rec.id && b.endLine < rec.startLine).pop() || null;
|
|
7227
|
+
endBurstWithoutResolve();
|
|
7228
|
+
const lineMap = { ids: {}, provisional: null };
|
|
7229
|
+
const ok = await commitTypingThenStructure({ typed: null, text: null,
|
|
7230
|
+
range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
|
|
7231
|
+
oldRange: [rec.startLine, rec.endLine], lineMap,
|
|
7232
|
+
pending: { surfaces: [editEl], serialize: () => ({ text: null, lineMap }) } });
|
|
7233
|
+
if (!ok) return;
|
|
7234
|
+
const anchor = preceding ? blockElAtLine(preceding.startLine) : null;
|
|
7235
|
+
if (preceding && !anchor) return;
|
|
7236
|
+
createTransient(anchor ? subtreeEndOf(anchor) : null, { focus: true });
|
|
7237
|
+
}
|
|
7238
|
+
|
|
7239
|
+
|
|
7240
|
+
// R31 (fix round 1): a transient WITH text next to the real block the key
|
|
7241
|
+
// was pressed in. Its text was never committed, so the join is ONE op on
|
|
7242
|
+
// the real block's range (after the block's own pending typing, D4): Delete
|
|
7243
|
+
// at the block's end pulls the transient's text up into the block;
|
|
7244
|
+
// Backspace at the block's start joins the block onto the end of the
|
|
7245
|
+
// transient's text, and the joined paragraph takes the block's lines.
|
|
7246
|
+
async function joinTransientIntoBlock(burst, editEl, transientEl, pullUp) {
|
|
7247
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
7248
|
+
const tSurface = blockContentEl(transientEl);
|
|
7249
|
+
if (!rec || !tSurface) return;
|
|
7250
|
+
const typed = typedInBurst(burst);
|
|
7251
|
+
const into = pullUp ? editEl : tSurface;
|
|
7252
|
+
const from = pullUp ? tSurface : editEl;
|
|
7253
|
+
const flatten = pullUp && burst.blockType === 'heading';
|
|
7254
|
+
let seam = 0;
|
|
7255
|
+
withFocusoutSuppressed(() => {
|
|
7256
|
+
seam = joinSeam(into, Array.prototype.slice.call(from.childNodes), flatten);
|
|
7257
|
+
into.focus();
|
|
7258
|
+
restoreSelection(into, seam, seam);
|
|
7259
|
+
if (pullUp) {
|
|
7260
|
+
removeTransient();
|
|
7261
|
+
} else {
|
|
7262
|
+
// The transient now holds the joined text and stands in for the block
|
|
7263
|
+
// on screen until the render; the `transient` slot lets go of it so
|
|
7264
|
+
// nothing resolves it as a transient of its own.
|
|
7265
|
+
editEl.closest('.ed-block').parentNode.removeChild(editEl.closest('.ed-block'));
|
|
7266
|
+
if (transient && transient.el === transientEl) transient = null;
|
|
7267
|
+
}
|
|
7268
|
+
});
|
|
7269
|
+
endBurstWithoutResolve();
|
|
7270
|
+
const text = () => (pullUp
|
|
7271
|
+
? surfaceCommitText(editEl, burst.blockType, burst.depth)
|
|
7272
|
+
: inlineMd.escapeBlockStarts(inlineMd.serializeInline(tSurface).md));
|
|
7273
|
+
const lineMap = { ids: { [rec.id]: rec.startLine }, provisional: null };
|
|
7274
|
+
const ok = await commitTypingThenStructure({ typed, text: text(),
|
|
7275
|
+
range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
|
|
7276
|
+
oldRange: [rec.startLine, rec.endLine], lineMap,
|
|
7277
|
+
pending: { surfaces: [into], serialize: () => ({ text: text(), lineMap }) } });
|
|
7278
|
+
if (ok) focusBlockAtLineOffset(rec.startLine, seam);
|
|
7279
|
+
}
|
|
7280
|
+
|
|
7281
|
+
async function deleteAtEnd(burst, editEl, blockEl, empty) {
|
|
7282
|
+
const next = nextBlockEl(blockEl);
|
|
7283
|
+
// Nothing below: nothing happens (an empty transient there is the last
|
|
7284
|
+
// block and stays, R12).
|
|
7285
|
+
if (!next) return;
|
|
7286
|
+
if (isTransient(next)) {
|
|
7287
|
+
if (transientSurfaceEmpty(next)) removeTransient();
|
|
7288
|
+
else if (!burst.transient) await joinTransientIntoBlock(burst, editEl, next, true);
|
|
7289
|
+
return;
|
|
7290
|
+
}
|
|
7291
|
+
const curIsTransient = !!burst.transient;
|
|
7292
|
+
const nextSurface = mergeSurfaceOf(next);
|
|
7293
|
+
const nextType = next.getAttribute('data-block-type');
|
|
7294
|
+
if (curIsTransient && empty) {
|
|
7295
|
+
removeTransient();
|
|
7296
|
+
if (nextSurface) caretToSurface(nextSurface, false);
|
|
7297
|
+
else selectBlock(next);
|
|
7298
|
+
return;
|
|
7299
|
+
}
|
|
7300
|
+
// D14: a list item is never pulled up into a paragraph; it is selected.
|
|
7301
|
+
if (!nextSurface || nextType === 'li') { selectBlock(next); return; }
|
|
7302
|
+
const nextRec = blockRecOf(next);
|
|
7303
|
+
const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
|
|
7304
|
+
if (!nextRec || (!curIsTransient && !curRec)) return;
|
|
7305
|
+
// Final review I2, as in joinIntoPrevious(): an emptied block's typing is
|
|
7306
|
+
// not an op of its own.
|
|
7307
|
+
const typed = empty ? null : typedInBurst(burst);
|
|
7308
|
+
let seam = 0;
|
|
7309
|
+
withFocusoutSuppressed(() => {
|
|
7310
|
+
seam = joinSeam(editEl, Array.prototype.slice.call(nextSurface.childNodes), burst.blockType === 'heading');
|
|
7311
|
+
restoreSelection(editEl, seam, seam);
|
|
7312
|
+
next.parentNode.removeChild(next);
|
|
7313
|
+
});
|
|
7314
|
+
endBurstWithoutResolve();
|
|
7315
|
+
const headId = curRec ? curRec.id : nextRec.id;
|
|
7316
|
+
const headLine = curRec ? curRec.startLine : nextRec.startLine;
|
|
7317
|
+
const serialize = () => ({
|
|
7318
|
+
text: curIsTransient
|
|
7319
|
+
? inlineMd.escapeBlockStarts(inlineMd.serializeInline(editEl).md)
|
|
7320
|
+
: surfaceCommitText(editEl, burst.blockType, burst.depth),
|
|
7321
|
+
lineMap: { ids: { [headId]: headLine }, provisional: null } });
|
|
7322
|
+
const s = serialize();
|
|
7323
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text,
|
|
7324
|
+
range: (bs) => {
|
|
7325
|
+
const n = bs.find((b) => b.id === nextRec.id);
|
|
7326
|
+
const c = curRec ? bs.find((b) => b.id === curRec.id) : null;
|
|
7327
|
+
return [c ? c.startLine : n.startLine, n.endLine];
|
|
7328
|
+
},
|
|
7329
|
+
oldRange: [headLine, nextRec.endLine], lineMap: s.lineMap,
|
|
7330
|
+
pending: { surfaces: [editEl], serialize } });
|
|
7331
|
+
if (ok) focusBlockAtLineOffset(headLine, seam);
|
|
7332
|
+
}
|
|
7333
|
+
|
|
5292
7334
|
// S2 Task 7 — the li half of +. The new item is spliced into the run's own
|
|
5293
7335
|
// span and the WHOLE span is re-serialized over the run's line range: one
|
|
5294
7336
|
// commitRangeEdit, therefore one undo op, no leading blank line (so the run
|
|
@@ -5679,12 +7721,12 @@
|
|
|
5679
7721
|
// never reported, never tested for duplicate/delete) — widening past
|
|
5680
7722
|
// convertBlockViaMenu() to the other three callers remains a separate
|
|
5681
7723
|
// decision this task did not make and should not make silently.
|
|
5682
|
-
function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
|
|
7724
|
+
function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue, caretToEnd) {
|
|
5683
7725
|
// 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
|
|
5684
7726
|
// 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
|
|
5685
7727
|
if (blockSelection) return;
|
|
5686
7728
|
if (focusLine != null) {
|
|
5687
|
-
focusBlockAtLine(focusLine,
|
|
7729
|
+
focusBlockAtLine(focusLine, caretToEnd !== false); // caretToEnd unless asked otherwise
|
|
5688
7730
|
// 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
|
|
5689
7731
|
// no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
|
|
5690
7732
|
reaimToolbarBlockAtLine(focusLine);
|
|
@@ -5804,7 +7846,10 @@
|
|
|
5804
7846
|
// The body returns the operated block's post-commit start line, or undefined
|
|
5805
7847
|
// when it refused or wrote nothing — `anchor.line` is what covers the
|
|
5806
7848
|
// second case (see restoreAfterStructuralOp()).
|
|
5807
|
-
|
|
7849
|
+
// `opts.caretToEnd === false` puts the caret at the start of the converted
|
|
7850
|
+
// block instead of its end (Backspace at a list item's start, final review
|
|
7851
|
+
// I1); the ⠿ menu passes nothing.
|
|
7852
|
+
async function convertBlockViaMenu(blockEl, target, opts) {
|
|
5808
7853
|
let focusLine = null;
|
|
5809
7854
|
const anchor = {};
|
|
5810
7855
|
try {
|
|
@@ -5820,7 +7865,7 @@
|
|
|
5820
7865
|
// Task 9 review I4: `true` — this is the ONE call site backlog #6's
|
|
5821
7866
|
// raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
|
|
5822
7867
|
// own `allowRawEditRescue` comment.
|
|
5823
|
-
restoreAfterStructuralOp(focusLine, anchor.line, true);
|
|
7868
|
+
restoreAfterStructuralOp(focusLine, anchor.line, true, !(opts && opts.caretToEnd === false));
|
|
5824
7869
|
}
|
|
5825
7870
|
}
|
|
5826
7871
|
|
|
@@ -5963,7 +8008,8 @@
|
|
|
5963
8008
|
// block the user never pointed at.
|
|
5964
8009
|
function reresolveBlockElAfterSelfCommit(identity) {
|
|
5965
8010
|
if (!identity) return null;
|
|
5966
|
-
const
|
|
8011
|
+
const startLine = identityStartLineNow(identity);
|
|
8012
|
+
const at = blocks.find((b) => b.startLine === startLine);
|
|
5967
8013
|
if (!at || at.type !== identity.type) return null;
|
|
5968
8014
|
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
5969
8015
|
}
|
|
@@ -7324,8 +9370,16 @@
|
|
|
7324
9370
|
function burstBaselineHtml(editEl) {
|
|
7325
9371
|
const html = editEl.innerHTML;
|
|
7326
9372
|
if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1 &&
|
|
7327
|
-
html.indexOf('ed-cell-range') === -1) return html;
|
|
9373
|
+
html.indexOf('ed-cell-range') === -1 && html.indexOf('search-hit') === -1) return html;
|
|
7328
9374
|
const clone = editEl.cloneNode(true);
|
|
9375
|
+
// v3.14.0 T9: the reader's search marks are transient chrome; unwrap them
|
|
9376
|
+
// and rejoin the text they cut.
|
|
9377
|
+
Array.prototype.slice.call(clone.querySelectorAll('mark.search-hit')).forEach((mk) => {
|
|
9378
|
+
const par = mk.parentNode;
|
|
9379
|
+
while (mk.firstChild) par.insertBefore(mk.firstChild, mk);
|
|
9380
|
+
par.removeChild(mk);
|
|
9381
|
+
par.normalize();
|
|
9382
|
+
});
|
|
7329
9383
|
const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging, .ed-cell-range');
|
|
7330
9384
|
Array.prototype.forEach.call(marked, (el) => {
|
|
7331
9385
|
el.classList.remove('ed-te-hl');
|
|
@@ -7345,6 +9399,32 @@
|
|
|
7345
9399
|
dropDiscardedRawEdit();
|
|
7346
9400
|
const blockEl = editEl.closest('.ed-block');
|
|
7347
9401
|
if (!blockEl) return;
|
|
9402
|
+
// v3.14.0 T1: a transient paragraph has no block record and no line. Its
|
|
9403
|
+
// burst carries `transient: true` and an empty baseline; resolveBurst()
|
|
9404
|
+
// commits it through commitLinesAtTransient() instead of commitEdit().
|
|
9405
|
+
if (blockEl.hasAttribute('data-ed-transient')) {
|
|
9406
|
+
const tHistory = historyLib.createBurstHistory(() => burstBaselineHtml(editEl), { debounceMs: 400 });
|
|
9407
|
+
tHistory.start();
|
|
9408
|
+
currentBurst = {
|
|
9409
|
+
blockEl, editEl, blockId: null, blockType: 'paragraph', transient: true,
|
|
9410
|
+
depth: null, original: '', history: tHistory,
|
|
9411
|
+
};
|
|
9412
|
+
watchBurstForDirtyDot(editEl);
|
|
9413
|
+
selToolbarEditEl = editEl;
|
|
9414
|
+
if (!selToolbarListener) {
|
|
9415
|
+
selToolbarListener = onSelectionChangeForToolbar;
|
|
9416
|
+
document.addEventListener('selectionchange', onSelectionChangeForToolbar);
|
|
9417
|
+
}
|
|
9418
|
+
return;
|
|
9419
|
+
}
|
|
9420
|
+
// R2 (F1): any OTHER id-less block (the provisional li an Enter builds
|
|
9421
|
+
// before its render lands) opens no burst. `Number(null)` is 0, so without
|
|
9422
|
+
// this the burst bound itself to block 0 and its commit could overwrite
|
|
9423
|
+
// the document's first line.
|
|
9424
|
+
// T6: also after that item's render FAILED. It is then part of a pending
|
|
9425
|
+
// run (setPendingListRun()), and the retry re-commits the whole live run,
|
|
9426
|
+
// this item's text included; a burst of its own would commit it alone.
|
|
9427
|
+
if (blockEl.getAttribute('data-block-id') === null) return;
|
|
7348
9428
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
7349
9429
|
const block = blocks.find((b) => b.id === blockId);
|
|
7350
9430
|
if (!block) return;
|
|
@@ -7404,6 +9484,9 @@
|
|
|
7404
9484
|
// ordinal into.
|
|
7405
9485
|
cancelBlockDrag();
|
|
7406
9486
|
if (!burst) return true;
|
|
9487
|
+
// v3.14.0 T1: a transient's burst never reaches the commitEdit() path
|
|
9488
|
+
// below — it has no block to commit over. See resolveTransientBurst().
|
|
9489
|
+
if (burst.transient) return await resolveTransientBurst(burst);
|
|
7407
9490
|
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
7408
9491
|
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
7409
9492
|
// OWN first burst resolves — one way (a real edit, below) or the
|
|
@@ -7579,11 +9662,19 @@
|
|
|
7579
9662
|
commitMd = runMd;
|
|
7580
9663
|
commitStart = range.startLine;
|
|
7581
9664
|
commitEnd = range.endLine;
|
|
9665
|
+
// v3.13.1 T1 fix round 3: the whole run is rewritten — including any
|
|
9666
|
+
// provisional item still in it, which then gets a line of its own and
|
|
9667
|
+
// pushes the items after it down one. Keys held for those items find
|
|
9668
|
+
// them by this map (see flightLineMapOf()). Measured without it: a
|
|
9669
|
+
// failed Enter render, then `ab` replayed into the new item, then the
|
|
9670
|
+
// blur commit of this run — `Z` held for charlie landed in `ab`.
|
|
9671
|
+
pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
|
|
7582
9672
|
}
|
|
7583
9673
|
const liCommitResult = (commitMd === '')
|
|
7584
9674
|
? commitRangeRemoval({ lines, blocks, stack }, commitStart, commitEnd)
|
|
7585
9675
|
: commitRangeEdit({ lines, blocks, stack }, commitStart, commitEnd, commitMd);
|
|
7586
9676
|
if (liCommitResult.op === null) {
|
|
9677
|
+
pendingFlightLineMap = null;
|
|
7587
9678
|
endBurstWithoutResolve();
|
|
7588
9679
|
return true;
|
|
7589
9680
|
}
|
|
@@ -7637,8 +9728,14 @@
|
|
|
7637
9728
|
// commit '#'.repeat(depth) + ' ' with nothing after the space. Every
|
|
7638
9729
|
// non-heading burst's `depth` is null (blockDepthOf() only computes it for
|
|
7639
9730
|
// 'heading'), so it takes the plain result.md branch.
|
|
7640
|
-
|
|
7641
|
-
|
|
9731
|
+
// v3.13.1 T2 (F-1): a paragraph's text is escaped where it would read as
|
|
9732
|
+
// a block marker (`## `, `- `, `> `, `1. ` …), or the next render turns
|
|
9733
|
+
// the paragraph into that block. A heading's text sits after its own
|
|
9734
|
+
// `# ` and needs none; a table's rows START with `|`, so its markdown
|
|
9735
|
+
// must never reach the escape — hence the blockType test, not just depth.
|
|
9736
|
+
// v3.14.0 T2 (R4): the rule itself lives in commitTextOf(), which the
|
|
9737
|
+
// split and the merge serialize through too.
|
|
9738
|
+
const newText = commitTextOf(result.md, burst.blockType, burst.depth);
|
|
7642
9739
|
// S1 removed the pre-per-li "a whole-list burst that serialized to ''"
|
|
7643
9740
|
// branch that lived here alongside the dead 'list' serializer arm above.
|
|
7644
9741
|
// The per-li equivalent — a run whose every item was removed — is handled
|
|
@@ -8193,11 +10290,30 @@
|
|
|
8193
10290
|
if (raw === null) return null;
|
|
8194
10291
|
const b = blocks.find((x) => x.id === Number(raw));
|
|
8195
10292
|
if (!b) return null;
|
|
8196
|
-
return { startLine: b.startLine, type: b.type, source: blockSourceOf(b)
|
|
10293
|
+
return { startLine: b.startLine, type: b.type, source: blockSourceOf(b),
|
|
10294
|
+
transientSeq: transientCommits.seq };
|
|
10295
|
+
}
|
|
10296
|
+
// v3.14.0 T1 fix round 1 (I2): where `identity`'s block starts NOW. A
|
|
10297
|
+
// transient's commit is a pure insertion — no existing line is rewritten —
|
|
10298
|
+
// so a block it moved is exactly `delta` lines further down, and the
|
|
10299
|
+
// fingerprint below still holds. Every other commit keeps the old rule
|
|
10300
|
+
// (its start line must not have moved, or the gesture is dropped). Measured
|
|
10301
|
+
// before: type into a transient, then + / ⠿ on any block below it — the
|
|
10302
|
+
// gesture's own switchAwayFrom() committed the transient, the target's
|
|
10303
|
+
// start line moved by ≥ 2, and the gesture was dropped with
|
|
10304
|
+
// DROPPED_GESTURE_MESSAGE.
|
|
10305
|
+
function identityStartLineNow(identity) {
|
|
10306
|
+
let line = identity.startLine;
|
|
10307
|
+
const since = identity.transientSeq === undefined ? transientCommits.seq : identity.transientSeq;
|
|
10308
|
+
transientCommits.log.forEach((c) => {
|
|
10309
|
+
if (c.seq > since && c.at <= line) line += c.delta;
|
|
10310
|
+
});
|
|
10311
|
+
return line;
|
|
8197
10312
|
}
|
|
8198
10313
|
function reresolveBlockEl(identity) {
|
|
8199
10314
|
if (!identity) return null;
|
|
8200
|
-
const
|
|
10315
|
+
const startLine = identityStartLineNow(identity);
|
|
10316
|
+
const at = blocks.find((b) => b.startLine === startLine);
|
|
8201
10317
|
if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
|
|
8202
10318
|
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
8203
10319
|
}
|
|
@@ -8354,7 +10470,7 @@
|
|
|
8354
10470
|
async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
|
|
8355
10471
|
const span = runEls || [];
|
|
8356
10472
|
const presetRange = opts && opts.presetRange;
|
|
8357
|
-
const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
|
|
10473
|
+
const { md, lineMeta } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
|
|
8358
10474
|
// The run's line range is read back off its own li blocks' ids — which
|
|
8359
10475
|
// requires at least one li to still BE there. A caller whose mutation
|
|
8360
10476
|
// removed the run's last item therefore captures the range BEFORE mutating
|
|
@@ -8383,6 +10499,10 @@
|
|
|
8383
10499
|
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
8384
10500
|
delta: result.op.after.length - result.op.before.length }
|
|
8385
10501
|
: undefined;
|
|
10502
|
+
// v3.13.1 T1 fix round 2: where each item of the run will start once this
|
|
10503
|
+
// commit lands, so a key held for an item of this very run — one the
|
|
10504
|
+
// split pushed down a line, or the provisional item itself — finds it.
|
|
10505
|
+
if (result.op !== null) pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
|
|
8386
10506
|
const ok = await safeRerenderAll(editRange);
|
|
8387
10507
|
if (!ok) {
|
|
8388
10508
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
@@ -8395,12 +10515,59 @@
|
|
|
8395
10515
|
// successful render (any later commit, undo, or redo) re-derives the DOM
|
|
8396
10516
|
// from `lines`. Same convention as insertBlockBelow() /
|
|
8397
10517
|
// deleteBlockViaGutter() / resolveBurst()'s own failure paths.
|
|
10518
|
+
// v3.14.0 T6 (D7, R14 widened): "until the next successful render"
|
|
10519
|
+
// used to mean the next render threw the screen's run away — measured:
|
|
10520
|
+
// Enter after `bravo`, the render fails, type `new`, leave: the file
|
|
10521
|
+
// had no `- new`. The run is now pending (retryPendingStructure()):
|
|
10522
|
+
// the next resolution, focus into any surface, or a structural key
|
|
10523
|
+
// re-commits the whole LIVE run over the same range first, provisional
|
|
10524
|
+
// item and anything typed into it included, as one op.
|
|
10525
|
+
setPendingListRun(span, range, opts && opts.carryOver);
|
|
8398
10526
|
return false;
|
|
8399
10527
|
}
|
|
8400
10528
|
if (focusStartLine != null) focusBlockAtLine(focusStartLine, caretToEnd);
|
|
8401
10529
|
return true;
|
|
8402
10530
|
}
|
|
8403
10531
|
|
|
10532
|
+
// T6: the pendingStructure slot for a list run whose commit did not land.
|
|
10533
|
+
// `span` is the post-mutation run the commit serialized; its provisional
|
|
10534
|
+
// item (no id) is the member the live run is re-derived from, else any
|
|
10535
|
+
// member still on screen. The bystander replay is the commit's own set —
|
|
10536
|
+
// `carry0` names exactly the items the gesture did not touch — re-read
|
|
10537
|
+
// from `lines` and minus an item typed into since (bystanderCarryOver()'s
|
|
10538
|
+
// dirty-burst rule), so a later edit in the run is not replayed away.
|
|
10539
|
+
function setPendingListRun(span, range, carry0) {
|
|
10540
|
+
const members = (span || []).slice();
|
|
10541
|
+
const liveMember = () => members.find((el) => el.isConnected && el.getAttribute('data-block-id') === null) ||
|
|
10542
|
+
members.find((el) => el.isConnected) || null;
|
|
10543
|
+
const liveRun = () => { const m = liveMember(); return m ? listRunOf(m) : []; };
|
|
10544
|
+
const surfaces = members.map(liTextEl).filter(Boolean);
|
|
10545
|
+
setPendingStructure(range.startLine, range.endLine, surfaces, (ps) => {
|
|
10546
|
+
const run = liveRun();
|
|
10547
|
+
if (!run.length) return { text: null, lineMap: null };
|
|
10548
|
+
let carry = null;
|
|
10549
|
+
if (carry0) {
|
|
10550
|
+
const fresh = bystanderCarryOver(run, null) || {};
|
|
10551
|
+
Object.keys(fresh).forEach((k) => {
|
|
10552
|
+
// Fix round 1 (I2): an item typed into after the failure, whose
|
|
10553
|
+
// burst an earlier (failed) retry ended, is read from the DOM.
|
|
10554
|
+
// Measured before: failed Enter, type in `charlie`, a second retry
|
|
10555
|
+
// fails, and the next commit wrote `- charlie` with no message.
|
|
10556
|
+
if (!(k in carry0) || (ps && ps.typedIds && ps.typedIds.has(k))) return;
|
|
10557
|
+
carry = carry || {};
|
|
10558
|
+
carry[k] = fresh[k];
|
|
10559
|
+
});
|
|
10560
|
+
}
|
|
10561
|
+
const res = serializeListEls(run, { carryOver: carry });
|
|
10562
|
+
return res.md === ''
|
|
10563
|
+
? { text: null, lineMap: null }
|
|
10564
|
+
: { text: res.md, lineMap: flightLineMapOf(res.lineMeta, range.startLine) };
|
|
10565
|
+
}, (el) => {
|
|
10566
|
+
const b = el && el.closest ? el.closest('.ed-block') : null;
|
|
10567
|
+
return !!b && liveRun().indexOf(b) !== -1;
|
|
10568
|
+
});
|
|
10569
|
+
}
|
|
10570
|
+
|
|
8404
10571
|
// The line `targetBlock`'s own marker will occupy once the run span it belongs
|
|
8405
10572
|
// to is committed by commitListStructure() above.
|
|
8406
10573
|
//
|
|
@@ -8584,6 +10751,34 @@
|
|
|
8584
10751
|
// this unconditionally qualifies as "abandoned" (no separate
|
|
8585
10752
|
// unchanged-check needed here, unlike resolveBurst()'s branch, where a
|
|
8586
10753
|
// real commit is also possible).
|
|
10754
|
+
// v3.14.0 T1: Escape in a transient abandons it — it wrote nothing, so
|
|
10755
|
+
// there is nothing to stash for Ctrl+Z. The only block of an empty
|
|
10756
|
+
// document stays (D3), emptied.
|
|
10757
|
+
if (burst.transient) {
|
|
10758
|
+
burst.history.dispose();
|
|
10759
|
+
currentBurst = null;
|
|
10760
|
+
resetSelToolbarState();
|
|
10761
|
+
editEl.innerHTML = '';
|
|
10762
|
+
syncEmptyMark(editEl);
|
|
10763
|
+
if (transientIsOnlyBlock(burst.blockEl)) return; // still focused, still empty
|
|
10764
|
+
// T8 (D8): the caret does not fall to the body — it goes back where
|
|
10765
|
+
// the gesture that made the transient was pressed, else to the end of
|
|
10766
|
+
// the block above, so the next key is typed somewhere.
|
|
10767
|
+
const t = transient;
|
|
10768
|
+
const prev = previousBlockEl(burst.blockEl);
|
|
10769
|
+
removeTransient();
|
|
10770
|
+
if (t && t.anchorCaret && t.anchorCaret.surface && t.anchorCaret.surface.isConnected) {
|
|
10771
|
+
t.anchorCaret.surface.focus();
|
|
10772
|
+
restoreSelection(t.anchorCaret.surface, t.anchorCaret.offset, t.anchorCaret.offset);
|
|
10773
|
+
return;
|
|
10774
|
+
}
|
|
10775
|
+
const surface = prev ? blockContentEl(prev) : null;
|
|
10776
|
+
if (surface && surface.classList.contains('ed-wys-armed')) {
|
|
10777
|
+
surface.focus();
|
|
10778
|
+
placeCaretAtEnd(surface);
|
|
10779
|
+
}
|
|
10780
|
+
return;
|
|
10781
|
+
}
|
|
8587
10782
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
8588
10783
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
8589
10784
|
// T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
|
|
@@ -8624,7 +10819,13 @@
|
|
|
8624
10819
|
};
|
|
8625
10820
|
currentBurst = null;
|
|
8626
10821
|
resetSelToolbarState();
|
|
8627
|
-
|
|
10822
|
+
// v3.14.0 T8 (D8): the caret stays in the reverted surface, at its end,
|
|
10823
|
+
// instead of blur(): the key typed right after Escape went nowhere before
|
|
10824
|
+
// (measured: X, Escape, Y saved nothing of Y). The surface has no burst
|
|
10825
|
+
// now; the next input into it starts one (ensureBurst()). Ctrl+Z still
|
|
10826
|
+
// finds the stash above first (restoreDiscardedBurst()).
|
|
10827
|
+
syncEmptyMark(editEl);
|
|
10828
|
+
placeCaretAtEnd(editEl);
|
|
8628
10829
|
}
|
|
8629
10830
|
|
|
8630
10831
|
// The table twin of the stash above — a table burst's editEl is the whole
|
|
@@ -8679,6 +10880,11 @@
|
|
|
8679
10880
|
: d.editEl;
|
|
8680
10881
|
if (!entry) { d.history.dispose(); return false; }
|
|
8681
10882
|
entry.focus();
|
|
10883
|
+
// T8: since Escape keeps the caret in the surface, focus() is usually a
|
|
10884
|
+
// no-op here and raises no focusin — open the burst directly.
|
|
10885
|
+
if (!currentBurst && document.activeElement === entry) {
|
|
10886
|
+
if (isTable) startTableBurst(entry); else startBurst(entry);
|
|
10887
|
+
}
|
|
8682
10888
|
if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
|
|
8683
10889
|
currentBurst.history.dispose();
|
|
8684
10890
|
currentBurst.history = d.history;
|
|
@@ -8776,9 +10982,47 @@
|
|
|
8776
10982
|
const state = burst.history.undo();
|
|
8777
10983
|
if (state !== null) {
|
|
8778
10984
|
editEl.innerHTML = state;
|
|
10985
|
+
syncEmptyMark(editEl);
|
|
8779
10986
|
placeCaretAtEnd(editEl); // best-effort — see the file-level caret-quirk note
|
|
8780
10987
|
return;
|
|
8781
10988
|
}
|
|
10989
|
+
// v3.14.0 T1: an empty transient with nothing left to step back through
|
|
10990
|
+
// locally. It wrote nothing, so the undo the user asked for is taking it
|
|
10991
|
+
// away again — not the document op before it. The caret goes back where
|
|
10992
|
+
// the gesture that made it was pressed (anchorCaret), else to the end of
|
|
10993
|
+
// the block above. In an empty document it is the only block and stays.
|
|
10994
|
+
if (burst.transient && transientSurfaceEmpty(burst.blockEl) && !transientIsOnlyBlock(burst.blockEl)) {
|
|
10995
|
+
const t = transient;
|
|
10996
|
+
const prev = previousBlockEl(burst.blockEl);
|
|
10997
|
+
removeTransient();
|
|
10998
|
+
if (t && t.anchorCaret && t.anchorCaret.surface && t.anchorCaret.surface.isConnected) {
|
|
10999
|
+
t.anchorCaret.surface.focus();
|
|
11000
|
+
restoreSelection(t.anchorCaret.surface, t.anchorCaret.offset, t.anchorCaret.offset);
|
|
11001
|
+
return;
|
|
11002
|
+
}
|
|
11003
|
+
const surface = prev ? blockContentEl(prev) : null;
|
|
11004
|
+
if (surface && surface.classList.contains('ed-wys-armed')) {
|
|
11005
|
+
surface.focus();
|
|
11006
|
+
placeCaretAtEnd(surface);
|
|
11007
|
+
}
|
|
11008
|
+
return;
|
|
11009
|
+
}
|
|
11010
|
+
// v3.14.0 T2: Enter at a block's start left an empty transient ABOVE the
|
|
11011
|
+
// caret and the burst stayed where it was. With nothing left to step back
|
|
11012
|
+
// through here, taking that transient away is the Enter's undo step; the
|
|
11013
|
+
// caret stays put. Only in the block the Enter was pressed in (`stayEl`):
|
|
11014
|
+
// a transient the user has already left behind is on its way out (its
|
|
11015
|
+
// removal waits for the press that left it), and a Ctrl+Z in another
|
|
11016
|
+
// block means that block's undo. MEASURED without the `stayEl` test (ER
|
|
11017
|
+
// 'burst undo: … cascades to the previous commit'): Enter at the end,
|
|
11018
|
+
// a click straight into the paragraph above, typing, Ctrl+Z twice — the
|
|
11019
|
+
// second one took the still-present transient instead of the commit.
|
|
11020
|
+
if (!burst.transient && transient && transient.byEnter && transient.stayEl === burst.blockEl &&
|
|
11021
|
+
transient.el.isConnected && transientSurfaceEmpty(transient.el) &&
|
|
11022
|
+
!transientIsOnlyBlock(transient.el)) {
|
|
11023
|
+
removeTransient();
|
|
11024
|
+
return;
|
|
11025
|
+
}
|
|
8782
11026
|
// §10-gap fix (review): if this burst is an untouched pristine insert,
|
|
8783
11027
|
// switchAwayFrom() below will itself auto-remove the block (resolveBurst()'s
|
|
8784
11028
|
// pristineInsert branch) — that auto-remove IS the undo the user just
|
|
@@ -8800,6 +11044,7 @@
|
|
|
8800
11044
|
const state = burst.history.redo();
|
|
8801
11045
|
if (state !== null) {
|
|
8802
11046
|
editEl.innerHTML = state;
|
|
11047
|
+
syncEmptyMark(editEl);
|
|
8803
11048
|
placeCaretAtEnd(editEl);
|
|
8804
11049
|
return;
|
|
8805
11050
|
}
|
|
@@ -8849,6 +11094,19 @@
|
|
|
8849
11094
|
//
|
|
8850
11095
|
// `reason` is metadata only — createBurstHistory().snap() does not read it
|
|
8851
11096
|
// (see lib/editor/history.js).
|
|
11097
|
+
// v3.14.0 T8 (D8): a focused armed surface can be without a burst — after
|
|
11098
|
+
// Escape (the caret stays), after Ctrl+S on an untouched surface — and
|
|
11099
|
+
// input there was never committed (typing) or did nothing (Enter,
|
|
11100
|
+
// Backspace, Delete). The next input starts the burst first. Never during
|
|
11101
|
+
// a flight or a resolution (those own the surface), never in a table cell
|
|
11102
|
+
// (its own burst opens on focus).
|
|
11103
|
+
function ensureBurst(surface) {
|
|
11104
|
+
if (currentBurst || switching || flightActive()) return;
|
|
11105
|
+
if (!surface || !surface.isConnected || !surface.classList.contains('ed-wys-armed')) return;
|
|
11106
|
+
if (surface.closest('.ed-wys-cell')) return;
|
|
11107
|
+
startBurst(surface);
|
|
11108
|
+
}
|
|
11109
|
+
|
|
8852
11110
|
function snapBurstIfActive(root, reason) {
|
|
8853
11111
|
if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
|
|
8854
11112
|
currentBurst.history.snap(reason);
|
|
@@ -8861,6 +11119,13 @@
|
|
|
8861
11119
|
// wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
|
|
8862
11120
|
// it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
|
|
8863
11121
|
function handleBurstKeydown(e, editEl) {
|
|
11122
|
+
// v3.14.0 T2 (R6): a structural key while a failed structure is still
|
|
11123
|
+
// pending re-commits that first — burst or no burst (the provisional half
|
|
11124
|
+
// of a failed split has none).
|
|
11125
|
+
if (pendingStructure && isStructuralKeyForRetry(e, editEl)) {
|
|
11126
|
+
retryStructureThenReplay(e, editEl);
|
|
11127
|
+
return;
|
|
11128
|
+
}
|
|
8864
11129
|
if (!currentBurst || currentBurst.editEl !== editEl) {
|
|
8865
11130
|
// v2.11.1 acceptance, escape class B. This bail is reachable with the
|
|
8866
11131
|
// surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
|
|
@@ -8913,6 +11178,11 @@
|
|
|
8913
11178
|
if (currentBurst.blockType === 'li') {
|
|
8914
11179
|
if (handleLiKeydown(e, editEl)) return;
|
|
8915
11180
|
}
|
|
11181
|
+
// v3.14.0 T3: Backspace at a paragraph / heading / transient start, Delete
|
|
11182
|
+
// at its end — join, convert or select (blockEdgeDelete()); anywhere else
|
|
11183
|
+
// the browser's own deletion.
|
|
11184
|
+
if ((e.key === 'Backspace' || e.key === 'Delete') &&
|
|
11185
|
+
blockEdgeDelete(e, editEl, e.key === 'Backspace' ? -1 : 1)) return;
|
|
8916
11186
|
// Task 6 — spec §3.5's other two rows. Tab is CONSUMED here: the
|
|
8917
11187
|
// alternative is not "nothing happens", it is the browser's own focus
|
|
8918
11188
|
// traversal walking the caret out of the document body, which is both a
|
|
@@ -8954,13 +11224,19 @@
|
|
|
8954
11224
|
return;
|
|
8955
11225
|
}
|
|
8956
11226
|
if (e.key === 'Enter') {
|
|
8957
|
-
e.preventDefault();
|
|
8958
11227
|
if (e.shiftKey) {
|
|
11228
|
+
e.preventDefault();
|
|
8959
11229
|
snapBurstIfActive(editEl, 'br-pre');
|
|
8960
11230
|
insertBrAtCaret();
|
|
8961
11231
|
snapBurstIfActive(editEl, 'br');
|
|
8962
|
-
} else {
|
|
11232
|
+
} else if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
11233
|
+
// A chord is not a paragraph break: it still commits by leaving.
|
|
11234
|
+
e.preventDefault();
|
|
8963
11235
|
editEl.blur();
|
|
11236
|
+
} else {
|
|
11237
|
+
// v3.14.0 T2: split, or open a transient at an edge. It calls
|
|
11238
|
+
// preventDefault() itself — never for an IME's confirming Enter.
|
|
11239
|
+
paragraphEnter(e, editEl).catch(structuralKeyFailed);
|
|
8964
11240
|
}
|
|
8965
11241
|
return;
|
|
8966
11242
|
}
|
|
@@ -9131,6 +11407,34 @@
|
|
|
9131
11407
|
//
|
|
9132
11408
|
// Returns the new block element, or null when the caret is not inside
|
|
9133
11409
|
// `blockEl`'s own surface (nothing mutated).
|
|
11410
|
+
// v3.13.1 T1: the item splitListItemAtCaret() just built is a plain div
|
|
11411
|
+
// until the commit's render replaces it — not editable, so the caret stayed
|
|
11412
|
+
// in the OLD item and anything typed before the render landed went there.
|
|
11413
|
+
// Arm it the way armEditables() arms an li (the same two writes), focus it
|
|
11414
|
+
// and put the caret at its start, where the render's own
|
|
11415
|
+
// focusBlockAtLine() will put it again.
|
|
11416
|
+
// The focusin this raises starts no burst: Enter's burst is still current
|
|
11417
|
+
// here, so that handler's `if (currentBurst) return;` returns.
|
|
11418
|
+
function armProvisionalLi(newLi) {
|
|
11419
|
+
const textEl = liTextEl(newLi);
|
|
11420
|
+
if (textEl) armProvisionalSurface(textEl);
|
|
11421
|
+
}
|
|
11422
|
+
// v3.14.0 T2: the same two writes for any provisional surface — the li's
|
|
11423
|
+
// `.ed-li-text`, or the <p> a paragraph split builds — then focus and the
|
|
11424
|
+
// caret at its start, where the commit's own focusBlockAtLine() puts it
|
|
11425
|
+
// again once the render lands.
|
|
11426
|
+
function armProvisionalSurface(el) {
|
|
11427
|
+
el.setAttribute('contenteditable', 'true');
|
|
11428
|
+
el.classList.add('ed-wys-armed');
|
|
11429
|
+
el.focus();
|
|
11430
|
+
const range = document.createRange();
|
|
11431
|
+
range.selectNodeContents(el);
|
|
11432
|
+
range.collapse(true);
|
|
11433
|
+
const sel = window.getSelection();
|
|
11434
|
+
sel.removeAllRanges();
|
|
11435
|
+
sel.addRange(range);
|
|
11436
|
+
}
|
|
11437
|
+
|
|
9134
11438
|
function splitListItemAtCaret(blockEl) {
|
|
9135
11439
|
const sel = window.getSelection();
|
|
9136
11440
|
if (!sel.rangeCount) return null;
|
|
@@ -9765,18 +12069,15 @@
|
|
|
9765
12069
|
|
|
9766
12070
|
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
9767
12071
|
// the block to a paragraph". Markdown cannot persist an EMPTY paragraph, so
|
|
9768
|
-
//
|
|
9769
|
-
//
|
|
9770
|
-
//
|
|
9771
|
-
//
|
|
9772
|
-
//
|
|
9773
|
-
//
|
|
9774
|
-
//
|
|
9775
|
-
//
|
|
9776
|
-
//
|
|
9777
|
-
// single undo op. Observed granularity (asserted in
|
|
9778
|
-
// test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
|
|
9779
|
-
// paragraph without popping the stack, Ctrl+Z #2 reverts the li removal.
|
|
12072
|
+
// the li is removed (ONE op, the run re-serialize) and a transient paragraph
|
|
12073
|
+
// takes its place, after whatever block now PRECEDES the removal point (the
|
|
12074
|
+
// end of its subtree when that is a li), or at the top of the document. The
|
|
12075
|
+
// transient writes nothing until something is typed into it.
|
|
12076
|
+
//
|
|
12077
|
+
// v3.14.0 T1 supersedes RULING F-J's two-step granularity (the ZWSP
|
|
12078
|
+
// paragraph was a second op): the key press is one undo op, and Ctrl+Z in
|
|
12079
|
+
// the still-empty transient takes the transient away without touching the
|
|
12080
|
+
// stack (burstUndo()).
|
|
9780
12081
|
async function convertEmptyTopLevelLiToParagraph(runEls, li) {
|
|
9781
12082
|
// Both captured BEFORE the mutation. The range, because removing the run's
|
|
9782
12083
|
// last item leaves commitListStructure() nothing to derive it from. The
|
|
@@ -9819,20 +12120,136 @@
|
|
|
9819
12120
|
const ok = await commitListStructure(survivors, null, false,
|
|
9820
12121
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
9821
12122
|
if (!ok) return;
|
|
9822
|
-
// Nothing precedes the removal point (the list opened the document):
|
|
9823
|
-
//
|
|
9824
|
-
//
|
|
9825
|
-
|
|
9826
|
-
if (!precedingBlock) return;
|
|
12123
|
+
// Nothing precedes the removal point (the list opened the document): the
|
|
12124
|
+
// transient opens the document, and its commit lands at the top
|
|
12125
|
+
// (commitInsertionAt() with no anchor).
|
|
12126
|
+
if (!precedingBlock) { createTransient(null, { focus: true }); return; }
|
|
9827
12127
|
const anchorEl = blockElAtLine(precedingBlock.startLine);
|
|
9828
12128
|
if (!anchorEl) return;
|
|
9829
|
-
|
|
12129
|
+
createTransient(subtreeEndOf(anchorEl), { focus: true });
|
|
12130
|
+
}
|
|
12131
|
+
|
|
12132
|
+
function liBackspaceAtStart(e, editEl) {
|
|
12133
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
|
|
12134
|
+
const place = caretPlace(editEl, true);
|
|
12135
|
+
if (place !== 'start' && place !== 'empty') return false;
|
|
12136
|
+
const li = closestLiBlock(editEl);
|
|
12137
|
+
if (!li) return false;
|
|
12138
|
+
if (!listRunOf(li).length) return false;
|
|
12139
|
+
e.preventDefault();
|
|
12140
|
+
liBackspaceStructure(li).catch(structuralKeyFailed);
|
|
12141
|
+
return true;
|
|
12142
|
+
}
|
|
12143
|
+
// R29 (fix round 1, I1): typing still in the item is committed as its own
|
|
12144
|
+
// op first (D4 wins over D14's single step), then the structure op runs on
|
|
12145
|
+
// the item as it now stands. Measured before: type X, Home, Backspace on a
|
|
12146
|
+
// nested item — one Ctrl+Z took back the X and the outdent together. Keys
|
|
12147
|
+
// typed while the typing op renders are held by the guard and replay after
|
|
12148
|
+
// the structure op (R7's shape).
|
|
12149
|
+
async function liBackspaceStructure(li) {
|
|
12150
|
+
const burst = currentBurst;
|
|
12151
|
+
if (burst && burst.blockType === 'li' && burst.blockEl === li &&
|
|
12152
|
+
burstBaselineHtml(burst.editEl) !== burst.original) {
|
|
12153
|
+
const rec = blockRecOf(li);
|
|
12154
|
+
if (!rec) return;
|
|
12155
|
+
if (!(await switchAwayFrom())) return;
|
|
12156
|
+
li = li.isConnected ? li : blockElAtLine(rec.startLine);
|
|
12157
|
+
if (!li) return;
|
|
12158
|
+
}
|
|
12159
|
+
const run = listRunOf(li);
|
|
12160
|
+
if (!run.length) return;
|
|
12161
|
+
if ((Number(li.getAttribute('data-indent')) || 0) > 0) {
|
|
12162
|
+
// Final review I1: the caret stays at the start of the item's text, as
|
|
12163
|
+
// in Notion, so a second Backspace converts it instead of deleting its
|
|
12164
|
+
// last character. Tab and Shift+Tab keep the caret at the end.
|
|
12165
|
+
liIndentStructure(li, run, true, false);
|
|
12166
|
+
return;
|
|
12167
|
+
}
|
|
12168
|
+
if (liOwnTextIsBlank(li)) {
|
|
12169
|
+
if (!listRunSupportsStructuralEdit(run, li)) { refuseStructuralListEdit(); return; }
|
|
12170
|
+
// RULING F-Q, as for Enter: an empty item that owns a sublist is not
|
|
12171
|
+
// removed from over its children.
|
|
12172
|
+
if (liBlockHasChildren(li)) return;
|
|
12173
|
+
await convertEmptyTopLevelLiToParagraph(run, li);
|
|
12174
|
+
return;
|
|
12175
|
+
}
|
|
12176
|
+
await convertBlockViaMenu(li, 'text', { caretToEnd: false });
|
|
12177
|
+
}
|
|
12178
|
+
|
|
12179
|
+
// Tab / Shift+Tab on a list item (and, v3.14.0 T3, Backspace at the start
|
|
12180
|
+
// of a nested one, which is Shift+Tab). Extracted unchanged from
|
|
12181
|
+
// handleLiKeydown(); `outdent` stands for the Shift key. `caretToEnd`
|
|
12182
|
+
// (default true, Tab's) is where the caret lands in the item afterwards;
|
|
12183
|
+
// Backspace passes false, the start of its text.
|
|
12184
|
+
function liIndentStructure(li, run, outdent, caretToEnd) {
|
|
12185
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
12186
|
+
// a hard-wrapped item is allowed to be the target here — see
|
|
12187
|
+
// listRunSupportsStructuralEdit()'s deviation note for the measurement.
|
|
12188
|
+
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
12189
|
+
refuseStructuralListEdit(); return true;
|
|
12190
|
+
}
|
|
12191
|
+
// Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
|
|
12192
|
+
// children keep their indent and become its siblings. Shift+Tab:
|
|
12193
|
+
// outdentListItem() raises it one level, takes its own subtree with it,
|
|
12194
|
+
// and adopts its former following same-level siblings. Both return false
|
|
12195
|
+
// at their respective boundary (no previous sibling / already top level),
|
|
12196
|
+
// which is a complete no-op — nothing mutated, nothing committed, file
|
|
12197
|
+
// byte-identical.
|
|
12198
|
+
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
12199
|
+
// INERT here: indentListItem()/outdentListItem() are attribute-only in
|
|
12200
|
+
// this flat model (setBlockIndent() on `li` and its subtree — see the
|
|
12201
|
+
// note on outdentListItem() further down this function) and never
|
|
12202
|
+
// detach the focused node, so the synchronous blur/focusout this span
|
|
12203
|
+
// exists to catch never fires, whether or not the flag is set.
|
|
12204
|
+
// Measured: primed a burst on `li`, ran this mutation, and no
|
|
12205
|
+
// focusout fired — `document.activeElement` stayed put and the node
|
|
12206
|
+
// never left the document. Kept for uniformity with the load-bearing
|
|
12207
|
+
// span in convertEmptyTopLevelLiToParagraph() above, not because this
|
|
12208
|
+
// one guards anything measured.
|
|
12209
|
+
const changed = mutateListRun(() => {
|
|
12210
|
+
if (!(outdent ? outdentListItem(li) : indentListItem(li))) return false;
|
|
12211
|
+
// v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
|
|
12212
|
+
// still had its OLD indent. That is fine for outdentListItem() — the
|
|
12213
|
+
// clamp's model rereads every element's CURRENT data-indent off the
|
|
12214
|
+
// DOM, so it self-corrects regardless of which elements the snapshot
|
|
12215
|
+
// names — but indentListItem() can now move a rule-(d) list-start
|
|
12216
|
+
// item (a bullet directly below an ordered item, say) whose own
|
|
12217
|
+
// pre-mutation run is `[li]` alone: nothing shallower ever preceded
|
|
12218
|
+
// it, because runBlocksOf() stops at a list-start by design. With no
|
|
12219
|
+
// anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
|
|
12220
|
+
// bound the indent this call just wrote against, and clamps it
|
|
12221
|
+
// straight back to 0 — silently undoing the write. A FRESH
|
|
12222
|
+
// listRunOf(li) call here — taken after the write above — walks back
|
|
12223
|
+
// from li's NEW indent and finds the real parent, exactly like the
|
|
12224
|
+
// post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
|
|
12225
|
+
// span) already does; using the same fresh call for the clamp keeps
|
|
12226
|
+
// the two consistent. Provably identical to the old stale `run` for
|
|
12227
|
+
// every case where li was never a list-start (listRunOf() slices by
|
|
12228
|
+
// INDEX RANGE, and neither the range's start/end index nor the
|
|
12229
|
+
// elements between them shift when only li's own indent changes).
|
|
12230
|
+
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
12231
|
+
return true;
|
|
12232
|
+
});
|
|
12233
|
+
if (!changed) return true;
|
|
12234
|
+
run = listRunOf(li);
|
|
12235
|
+
// Column-only: nothing's CONTENT moved, so every block in the span —
|
|
12236
|
+
// the target included — is a bystander whose source bytes must come
|
|
12237
|
+
// back untouched. Built once and shared with runLineOfBlock() below,
|
|
12238
|
+
// which indexes into the lines this very map decides.
|
|
12239
|
+
const carry = bystanderCarryOver(run, null);
|
|
12240
|
+
commitListStructure(run, runLineOfBlock(run, li, carry), caretToEnd !== false, { carryOver: carry });
|
|
12241
|
+
return true;
|
|
9830
12242
|
}
|
|
9831
12243
|
|
|
9832
12244
|
// Returns true when the key was CONSUMED (Enter/Tab, incl. their no-op
|
|
9833
12245
|
// outcomes); false lets handleBurstKeydown()'s shared Escape / Ctrl+Z /
|
|
9834
12246
|
// Ctrl+Y branches run unchanged.
|
|
9835
12247
|
function handleLiKeydown(e, editEl) {
|
|
12248
|
+
// v3.14.0 T3 (D14): Backspace at an item's start. Nested: Shift+Tab's
|
|
12249
|
+
// outdent. Top level with text: ⠿ 轉換成 文字. Top level and empty: the
|
|
12250
|
+
// item goes and a transient paragraph takes its place (Enter's row-3
|
|
12251
|
+
// path). Anywhere else the browser's own deletion.
|
|
12252
|
+
if (e.key === 'Backspace') return liBackspaceAtStart(e, editEl);
|
|
9836
12253
|
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
9837
12254
|
e.preventDefault();
|
|
9838
12255
|
// T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
|
|
@@ -9861,65 +12278,7 @@
|
|
|
9861
12278
|
let run = listRunOf(li);
|
|
9862
12279
|
if (!run.length) return true;
|
|
9863
12280
|
|
|
9864
|
-
if (e.key === 'Tab')
|
|
9865
|
-
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
9866
|
-
// a hard-wrapped item is allowed to be the target here — see
|
|
9867
|
-
// listRunSupportsStructuralEdit()'s deviation note for the measurement.
|
|
9868
|
-
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
9869
|
-
refuseStructuralListEdit(); return true;
|
|
9870
|
-
}
|
|
9871
|
-
// Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
|
|
9872
|
-
// children keep their indent and become its siblings. Shift+Tab:
|
|
9873
|
-
// outdentListItem() raises it one level, takes its own subtree with it,
|
|
9874
|
-
// and adopts its former following same-level siblings. Both return false
|
|
9875
|
-
// at their respective boundary (no previous sibling / already top level),
|
|
9876
|
-
// which is a complete no-op — nothing mutated, nothing committed, file
|
|
9877
|
-
// byte-identical.
|
|
9878
|
-
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
9879
|
-
// INERT here: indentListItem()/outdentListItem() are attribute-only in
|
|
9880
|
-
// this flat model (setBlockIndent() on `li` and its subtree — see the
|
|
9881
|
-
// note on outdentListItem() further down this function) and never
|
|
9882
|
-
// detach the focused node, so the synchronous blur/focusout this span
|
|
9883
|
-
// exists to catch never fires, whether or not the flag is set.
|
|
9884
|
-
// Measured: primed a burst on `li`, ran this mutation, and no
|
|
9885
|
-
// focusout fired — `document.activeElement` stayed put and the node
|
|
9886
|
-
// never left the document. Kept for uniformity with the load-bearing
|
|
9887
|
-
// span in convertEmptyTopLevelLiToParagraph() above, not because this
|
|
9888
|
-
// one guards anything measured.
|
|
9889
|
-
const changed = mutateListRun(() => {
|
|
9890
|
-
if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
|
|
9891
|
-
// v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
|
|
9892
|
-
// still had its OLD indent. That is fine for outdentListItem() — the
|
|
9893
|
-
// clamp's model rereads every element's CURRENT data-indent off the
|
|
9894
|
-
// DOM, so it self-corrects regardless of which elements the snapshot
|
|
9895
|
-
// names — but indentListItem() can now move a rule-(d) list-start
|
|
9896
|
-
// item (a bullet directly below an ordered item, say) whose own
|
|
9897
|
-
// pre-mutation run is `[li]` alone: nothing shallower ever preceded
|
|
9898
|
-
// it, because runBlocksOf() stops at a list-start by design. With no
|
|
9899
|
-
// anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
|
|
9900
|
-
// bound the indent this call just wrote against, and clamps it
|
|
9901
|
-
// straight back to 0 — silently undoing the write. A FRESH
|
|
9902
|
-
// listRunOf(li) call here — taken after the write above — walks back
|
|
9903
|
-
// from li's NEW indent and finds the real parent, exactly like the
|
|
9904
|
-
// post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
|
|
9905
|
-
// span) already does; using the same fresh call for the clamp keeps
|
|
9906
|
-
// the two consistent. Provably identical to the old stale `run` for
|
|
9907
|
-
// every case where li was never a list-start (listRunOf() slices by
|
|
9908
|
-
// INDEX RANGE, and neither the range's start/end index nor the
|
|
9909
|
-
// elements between them shift when only li's own indent changes).
|
|
9910
|
-
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
9911
|
-
return true;
|
|
9912
|
-
});
|
|
9913
|
-
if (!changed) return true;
|
|
9914
|
-
run = listRunOf(li);
|
|
9915
|
-
// Column-only: nothing's CONTENT moved, so every block in the span —
|
|
9916
|
-
// the target included — is a bystander whose source bytes must come
|
|
9917
|
-
// back untouched. Built once and shared with runLineOfBlock() below,
|
|
9918
|
-
// which indexes into the lines this very map decides.
|
|
9919
|
-
const carry = bystanderCarryOver(run, null);
|
|
9920
|
-
commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
9921
|
-
return true;
|
|
9922
|
-
}
|
|
12281
|
+
if (e.key === 'Tab') return liIndentStructure(li, run, e.shiftKey);
|
|
9923
12282
|
|
|
9924
12283
|
// Enter.
|
|
9925
12284
|
if (selectionSpansMultipleListItems()) {
|
|
@@ -10018,7 +12377,14 @@
|
|
|
10018
12377
|
// splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
|
|
10019
12378
|
// freshly-inserted sibling) and never touches the focused node's
|
|
10020
12379
|
// attachment.
|
|
10021
|
-
|
|
12380
|
+
// v3.13.1 T1: the new item is armed and focused inside the same span, so
|
|
12381
|
+
// the old item's focusout stays suppressed and the next keystroke is
|
|
12382
|
+
// already aimed at the new item while the commit below is in flight.
|
|
12383
|
+
const newLi = mutateListRun(() => {
|
|
12384
|
+
const n = splitListItemAtCaret(li);
|
|
12385
|
+
if (n) armProvisionalLi(n);
|
|
12386
|
+
return n;
|
|
12387
|
+
});
|
|
10022
12388
|
if (!newLi) return true;
|
|
10023
12389
|
run = listRunOf(newLi);
|
|
10024
12390
|
// `li` had its text CUT IN TWO in the DOM; replaying its source would put
|
|
@@ -10945,8 +13311,27 @@
|
|
|
10945
13311
|
while (n && !n.classList.contains('ed-block')) n = step(n);
|
|
10946
13312
|
return n;
|
|
10947
13313
|
};
|
|
13314
|
+
// v3.14.0 T5: leaving a transient. Empty: it wrote nothing, so it is
|
|
13315
|
+
// removed and the walk lands on the real neighbour — no render. With
|
|
13316
|
+
// text: it is committed first (commitLinesAtTransient() records
|
|
13317
|
+
// transientLandedLine) and the walk goes on from the paragraph it became.
|
|
13318
|
+
if (isTransient(fromBlockEl)) {
|
|
13319
|
+
if (transientSurfaceEmpty(fromBlockEl)) {
|
|
13320
|
+
const target = nextBlock(fromBlockEl);
|
|
13321
|
+
if (!target) return;
|
|
13322
|
+
if (!transientIsOnlyBlock(fromBlockEl)) removeTransient();
|
|
13323
|
+
if (x !== null) arrowGoalX = x;
|
|
13324
|
+
arrowLandOn(target, delta, x !== null ? x : arrowGoalX);
|
|
13325
|
+
return;
|
|
13326
|
+
}
|
|
13327
|
+
transientLandedLine = null;
|
|
13328
|
+
if (!(await switchAwayFrom()) || transientLandedLine === null) return;
|
|
13329
|
+
fromBlockEl = blockElAtLine(transientLandedLine);
|
|
13330
|
+
if (!fromBlockEl) return;
|
|
13331
|
+
}
|
|
10948
13332
|
let dest = nextBlock(fromBlockEl);
|
|
10949
13333
|
if (!dest) return;
|
|
13334
|
+
const destWasTransient = isTransient(dest);
|
|
10950
13335
|
const srcStartLine = (blockRecOf(fromBlockEl) || {}).startLine;
|
|
10951
13336
|
const destIdentity = captureBlockIdentity(dest);
|
|
10952
13337
|
const ok = await switchAwayFrom();
|
|
@@ -10956,7 +13341,9 @@
|
|
|
10956
13341
|
const liveSrc = srcRec
|
|
10957
13342
|
? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
|
|
10958
13343
|
dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
|
|
10959
|
-
|
|
13344
|
+
// A transient destination is gone with the render that committed the
|
|
13345
|
+
// source; there is nothing to re-resolve and nothing was dropped.
|
|
13346
|
+
if (!dest) { if (!destWasTransient) showError(DROPPED_GESTURE_MESSAGE); return; }
|
|
10960
13347
|
}
|
|
10961
13348
|
if (x !== null) arrowGoalX = x;
|
|
10962
13349
|
arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
|
|
@@ -16067,6 +18454,9 @@
|
|
|
16067
18454
|
// expression setDirty() paints the title dot from — not a second copy
|
|
16068
18455
|
// of it.
|
|
16069
18456
|
dirty: documentIsDirty(),
|
|
18457
|
+
// v3.14.0 T4 (D15): 'empty' | 'text' on a transient paragraph, else null.
|
|
18458
|
+
transient: blockEl && isTransient(blockEl)
|
|
18459
|
+
? (transientSurfaceEmpty(blockEl) ? 'empty' : 'text') : null,
|
|
16070
18460
|
};
|
|
16071
18461
|
}
|
|
16072
18462
|
|
|
@@ -16447,6 +18837,12 @@
|
|
|
16447
18837
|
}
|
|
16448
18838
|
|
|
16449
18839
|
async function runToolbarAction(id, btnEl, viaKeyboard) {
|
|
18840
|
+
// R32 audit: a toolbar action can write `lines` without a resolution
|
|
18841
|
+
// first (indentCaretLi() commits a list structure directly), so a pending
|
|
18842
|
+
// structure is re-committed before any of them runs; if that fails the
|
|
18843
|
+
// press is dropped (the error card stays).
|
|
18844
|
+
if (pendingStructure && !(await retryPendingStructure())) return;
|
|
18845
|
+
if (await toolbarOnTransient(id, liveToolbarBlock())) return;
|
|
16450
18846
|
const blockEl = liveToolbarBlock();
|
|
16451
18847
|
switch (id) {
|
|
16452
18848
|
// v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
|
|
@@ -16483,8 +18879,8 @@
|
|
|
16483
18879
|
case 'indent': return indentCaretLi(1);
|
|
16484
18880
|
case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
|
|
16485
18881
|
case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
|
|
16486
|
-
case 'insert-after': return blockEl ?
|
|
16487
|
-
case 'insert-before': return blockEl ?
|
|
18882
|
+
case 'insert-after': return blockEl ? insertTransientBelow(blockEl) : undefined;
|
|
18883
|
+
case 'insert-before': return blockEl ? insertTransientAbove(blockEl) : undefined;
|
|
16488
18884
|
// backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
|
|
16489
18885
|
// See its own comment (shared with undoViaToolbar()/redoViaToolbar())
|
|
16490
18886
|
// for the caret rescue.
|
|
@@ -16598,6 +18994,7 @@
|
|
|
16598
18994
|
// gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
|
|
16599
18995
|
// heading gets a delta computed against its own current depth.
|
|
16600
18996
|
async function applyHeadingLevel(n) {
|
|
18997
|
+
if (await toolbarOnTransient('h' + n, liveToolbarBlock())) return;
|
|
16601
18998
|
const blockEl = liveToolbarBlock();
|
|
16602
18999
|
if (!blockEl) return;
|
|
16603
19000
|
if (blockEl.getAttribute('data-block-type') !== 'heading') {
|
|
@@ -16612,79 +19009,12 @@
|
|
|
16612
19009
|
}
|
|
16613
19010
|
|
|
16614
19011
|
// ── insert above ───────────────────────────────────────────────────────
|
|
16615
|
-
//
|
|
16616
|
-
//
|
|
16617
|
-
//
|
|
16618
|
-
//
|
|
16619
|
-
//
|
|
16620
|
-
|
|
16621
|
-
const rec = blockRecOf(blockEl);
|
|
16622
|
-
if (!rec) return;
|
|
16623
|
-
const preceding = blocks
|
|
16624
|
-
.filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
|
|
16625
|
-
.pop();
|
|
16626
|
-
if (preceding) {
|
|
16627
|
-
const anchorEl = blockElAtLine(preceding.startLine);
|
|
16628
|
-
if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
|
|
16629
|
-
}
|
|
16630
|
-
// Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
|
|
16631
|
-
// instead would leave the document's FIRST block permanently impossible
|
|
16632
|
-
// to precede: the ⠿ and + menus both insert below, so this button is the
|
|
16633
|
-
// only affordance that reaches that position at all.
|
|
16634
|
-
await insertParagraphAtTop(blockEl);
|
|
16635
|
-
}
|
|
16636
|
-
|
|
16637
|
-
// The document's first block has no predecessor to insert BELOW, and
|
|
16638
|
-
// commitBlockInsertion() only ever inserts after a block's range. Rather
|
|
16639
|
-
// than teach the pure core a direction (a change under byte-stability and
|
|
16640
|
-
// roundtrip fixtures), the insert is expressed as a REPLACE of that
|
|
16641
|
-
// block's own first source line by [skeleton, '', that same line] — the
|
|
16642
|
-
// same commitRangeEdit()/replaceLines() pipeline as every other edit,
|
|
16643
|
-
// therefore one undo op, and no new primitive.
|
|
16644
|
-
//
|
|
16645
|
-
// The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
|
|
16646
|
-
// for its reasons: the resolution can commit a dirty burst, whose
|
|
16647
|
-
// rerenderAll() detaches the very node this was called with.
|
|
16648
|
-
async function insertParagraphAtTop(blockEl) {
|
|
16649
|
-
const identity = captureBlockIdentity(blockEl);
|
|
16650
|
-
const selfSession = ownsOpenSession(blockEl);
|
|
16651
|
-
const ok = await switchAwayFrom();
|
|
16652
|
-
if (!ok) return;
|
|
16653
|
-
let liveBlockEl = blockEl;
|
|
16654
|
-
if (!document.body.contains(blockEl)) {
|
|
16655
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
16656
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
16657
|
-
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
16658
|
-
}
|
|
16659
|
-
const rec = blockRecOf(liveBlockEl);
|
|
16660
|
-
// A block that owns no source line has an inverted range, and there is
|
|
16661
|
-
// no line to splice in front of.
|
|
16662
|
-
if (!rec || rec.endLine < rec.startLine) {
|
|
16663
|
-
refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
|
|
16664
|
-
return;
|
|
16665
|
-
}
|
|
16666
|
-
const at = rec.startLine;
|
|
16667
|
-
const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
|
|
16668
|
-
const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
|
|
16669
|
-
if (result.op === null) return;
|
|
16670
|
-
const prevLines = lines;
|
|
16671
|
-
lines = result.lines;
|
|
16672
|
-
const editRange = result.op
|
|
16673
|
-
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
16674
|
-
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
16675
|
-
delta: result.op.after.length - result.op.before.length }
|
|
16676
|
-
: undefined;
|
|
16677
|
-
const okRender = await safeRerenderAll(editRange);
|
|
16678
|
-
if (!okRender) {
|
|
16679
|
-
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
16680
|
-
return;
|
|
16681
|
-
}
|
|
16682
|
-
// The new paragraph starts exactly where the old first line did — same
|
|
16683
|
-
// pristine + focus contract insertBlockBelow() gives a skeleton insert.
|
|
16684
|
-
const target = blocks.find((b) => b.startLine === at);
|
|
16685
|
-
if (target) pristineInsert = { blockId: target.id };
|
|
16686
|
-
await focusInsertedBlock(at, 'paragraph');
|
|
16687
|
-
}
|
|
19012
|
+
// v3.14.0 T1: 在上方插入 opens a transient above the block — see
|
|
19013
|
+
// insertTransientAbove(). It used to write a ZWSP paragraph, through
|
|
19014
|
+
// insertBlockBelow() on the preceding block or, for the document's first
|
|
19015
|
+
// block, a splice over that block's own first line; a transient needs
|
|
19016
|
+
// neither, because it writes nothing until it is committed, and its commit
|
|
19017
|
+
// (commitInsertionAt()) already covers the top of the document.
|
|
16688
19018
|
|
|
16689
19019
|
// ── 大綱 ───────────────────────────────────────────────────────────────
|
|
16690
19020
|
// TWO attributes, chosen by viewport, and that is not an accident.
|
|
@@ -16893,6 +19223,12 @@
|
|
|
16893
19223
|
if (i > 0) newLines.push('');
|
|
16894
19224
|
newLines.push(' + ')');
|
|
16895
19225
|
});
|
|
19226
|
+
// v3.14.0 T4: a transient has no source line to insert below; the images
|
|
19227
|
+
// replace it (after its own text, if it has any), as one op.
|
|
19228
|
+
if (isTransient(target)) {
|
|
19229
|
+
await commitPastedLinesAtTransient(target, newLines);
|
|
19230
|
+
return;
|
|
19231
|
+
}
|
|
16896
19232
|
await insertBlockBelow(target, 'paragraph', { lines: newLines });
|
|
16897
19233
|
}
|
|
16898
19234
|
|
|
@@ -17126,7 +19462,14 @@
|
|
|
17126
19462
|
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
17127
19463
|
// foreign nodes the serializer would have to make sense of afterwards.
|
|
17128
19464
|
const pastedLines = md.split('\n');
|
|
17129
|
-
|
|
19465
|
+
// v3.14.0 T5: in a transient the pasted blocks replace it (after its
|
|
19466
|
+
// own text, if any) as one op.
|
|
19467
|
+
const hostBlock = editEl.closest('.ed-block');
|
|
19468
|
+
if (hostBlock && isTransient(hostBlock)) {
|
|
19469
|
+
commitPastedLinesAtTransient(hostBlock, pastedLines);
|
|
19470
|
+
return;
|
|
19471
|
+
}
|
|
19472
|
+
insertBlockBelow(hostBlock, 'paragraph',
|
|
17130
19473
|
{ lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
|
|
17131
19474
|
return;
|
|
17132
19475
|
}
|
|
@@ -17550,6 +19893,46 @@
|
|
|
17550
19893
|
e.target.classList.contains('ed-raw');
|
|
17551
19894
|
if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
|
|
17552
19895
|
|
|
19896
|
+
// v3.13.1 T1: a key that edits or moves, pressed while a commit is in
|
|
19897
|
+
// flight, would act on DOM the render is about to replace. Held, in order
|
|
19898
|
+
// with the text around it, and replayed by flushFlightBuffer() through
|
|
19899
|
+
// this same handler (see `commitFlight`). An IME's own keys (isComposing)
|
|
19900
|
+
// are left alone. Escape drops everything held (R12) and then carries on
|
|
19901
|
+
// to its usual meaning. A standing table cell range keeps its own keys —
|
|
19902
|
+
// its Delete clears the range, which a held character delete would not.
|
|
19903
|
+
if (flightActive() && !e.isComposing && !cellRangeBurst()) {
|
|
19904
|
+
const flightSurface = flightSurfaceOf(e.target);
|
|
19905
|
+
if (flightSurface && e.key === 'Escape') { flightBuffer.length = 0; flightSelMoved = false; }
|
|
19906
|
+
// Ctrl/Cmd+Z with keys held: the most recent thing to undo is that
|
|
19907
|
+
// typing, which never reached the document — dropping it IS the undo,
|
|
19908
|
+
// and the commit in flight stays (the next Ctrl+Z, with nothing held,
|
|
19909
|
+
// undoes it as usual). Measured before: the undo reverted the Enter
|
|
19910
|
+
// underneath the held keys, and they replayed into the next item
|
|
19911
|
+
// (`abccharlie`). Held navigation (and a click's focus entry) is not
|
|
19912
|
+
// typing and stays; a dropped entry's position passes to the next kept
|
|
19913
|
+
// entry of its run.
|
|
19914
|
+
if (flightSurface && flightBuffer.some(isHeldTyping) && (e.ctrlKey || e.metaKey) && !e.shiftKey &&
|
|
19915
|
+
!e.altKey && (e.key === 'z' || e.key === 'Z')) {
|
|
19916
|
+
e.preventDefault();
|
|
19917
|
+
dropHeldTyping();
|
|
19918
|
+
return;
|
|
19919
|
+
}
|
|
19920
|
+
// v3.14.0 T7 (D9): Ctrl/Cmd+B/I/U is held too and replays as the same
|
|
19921
|
+
// native command — let through, it formatted DOM the render replaced.
|
|
19922
|
+
const formatCommand = flightSurface ? formatCommandForKey(e) : null;
|
|
19923
|
+
if (formatCommand) {
|
|
19924
|
+
e.preventDefault();
|
|
19925
|
+
holdFlightEntry(flightSurface, { kind: 'format', command: formatCommand });
|
|
19926
|
+
return;
|
|
19927
|
+
}
|
|
19928
|
+
if (flightSurface && flightHoldsKey(e)) {
|
|
19929
|
+
e.preventDefault();
|
|
19930
|
+
holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
|
|
19931
|
+
ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
|
|
19932
|
+
return;
|
|
19933
|
+
}
|
|
19934
|
+
}
|
|
19935
|
+
|
|
17553
19936
|
// Task 5: a table cell (class 'ed-wys-cell') owns its own Enter/Tab/
|
|
17554
19937
|
// Escape/Ctrl+Z/Ctrl+Y contract, materially different from paragraph/
|
|
17555
19938
|
// heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
|
|
@@ -17590,6 +19973,8 @@
|
|
|
17590
19973
|
// it either.
|
|
17591
19974
|
const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
17592
19975
|
if (wysArmedEl && burstOwnsKey) {
|
|
19976
|
+
// T8: Enter / Backspace / Delete need the burst their handlers act in.
|
|
19977
|
+
if (!currentBurst && (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete')) ensureBurst(wysArmedEl);
|
|
17593
19978
|
handleBurstKeydown(e, wysArmedEl);
|
|
17594
19979
|
return;
|
|
17595
19980
|
}
|
|
@@ -17789,8 +20174,17 @@
|
|
|
17789
20174
|
// input/paste handling (class 'ed-wys-cell') is a separate branch at the
|
|
17790
20175
|
// top of each listener below — see handleTableCellFocusIn() above.
|
|
17791
20176
|
document.addEventListener('focusin', async (e) => {
|
|
20177
|
+
// v3.14.0 T2 (D7): focus going into any surface while a failed structure
|
|
20178
|
+
// is pending re-commits it first. Opening a burst over its range would
|
|
20179
|
+
// commit the left half over the whole block, and a commit anywhere else
|
|
20180
|
+
// renders and drops the screen half. The wait below then re-resolves this
|
|
20181
|
+
// surface across that render like any other resolution's.
|
|
20182
|
+
if (pendingStructure && !switching &&
|
|
20183
|
+
e.target && e.target.closest && e.target.closest('.ed-wys-armed, .ed-wys-cell')) {
|
|
20184
|
+
switchAwayFrom();
|
|
20185
|
+
}
|
|
17792
20186
|
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
17793
|
-
if (cellEl) { await handleTableCellFocusIn(cellEl); return; }
|
|
20187
|
+
if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
|
|
17794
20188
|
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
17795
20189
|
if (!editEl) return;
|
|
17796
20190
|
if (currentBurst && currentBurst.editEl === editEl) return; // already tracking
|
|
@@ -17800,8 +20194,33 @@
|
|
|
17800
20194
|
// be resolving from the focusout that just preceded this focusin — see
|
|
17801
20195
|
// switchAwayFrom()'s single-flight `switching`. Await it before
|
|
17802
20196
|
// starting a new burst so the two never race a concurrent commit.
|
|
17803
|
-
|
|
17804
|
-
|
|
20197
|
+
// v3.14.0 T1 fix round 1 (I1): the wait is tracked as a flight tag, so a
|
|
20198
|
+
// render that lands meanwhile re-points it at the block that now holds
|
|
20199
|
+
// the same source. Measured before: a press below a transient whose
|
|
20200
|
+
// commit inserted lines re-resolved by the OLD id and put the caret, and
|
|
20201
|
+
// the keys typed after the press, into the transient's new paragraph.
|
|
20202
|
+
// v3.14.0 T2: the provisional half of a pending split is tagged too — it
|
|
20203
|
+
// has no id, and its tag re-points through the retry's `provisional` line.
|
|
20204
|
+
const pendingHalf = !!(pendingStructure && pendingStructure.surfaces.indexOf(editEl) !== -1);
|
|
20205
|
+
const waitTag = switching && (blockId !== null || pendingHalf)
|
|
20206
|
+
? { surface: editEl, blockId, cellIndex: null, anchor: null, focus: null } : null;
|
|
20207
|
+
if (waitTag) focusinWaitTags.push(waitTag);
|
|
20208
|
+
try {
|
|
20209
|
+
if (switching) await switching;
|
|
20210
|
+
} finally {
|
|
20211
|
+
if (waitTag) focusinWaitTags.splice(focusinWaitTags.indexOf(waitTag), 1);
|
|
20212
|
+
}
|
|
20213
|
+
// v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
|
|
20214
|
+
// arrow walk lands on the NEXT block from inside that same resolution, and
|
|
20215
|
+
// its own focusin is queued behind this one. Starting a burst here would
|
|
20216
|
+
// open it on a surface that no longer has focus and leave the one that
|
|
20217
|
+
// does without one: measured on base and head, Enter, `ab`, ArrowDown, `Z`
|
|
20218
|
+
// put `Z` in charlie's DOM, and the blur that followed committed nothing.
|
|
20219
|
+
if (document.body.contains(editEl) && !editEl.contains(document.activeElement)) {
|
|
20220
|
+
scheduleFlightFlush();
|
|
20221
|
+
return;
|
|
20222
|
+
}
|
|
20223
|
+
if (currentBurst) { scheduleFlightFlush(); return; } // a concurrent focusin already won the race
|
|
17805
20224
|
let liveEditEl = editEl;
|
|
17806
20225
|
if (!document.body.contains(editEl)) {
|
|
17807
20226
|
// The awaited resolution above committed successfully and swapped the
|
|
@@ -17809,13 +20228,28 @@
|
|
|
17809
20228
|
// target. Re-resolve the equivalent LIVE node by block id and move
|
|
17810
20229
|
// focus there for real — that re-enters this same handler
|
|
17811
20230
|
// synchronously (switching is null by now), which starts the burst.
|
|
17812
|
-
|
|
20231
|
+
// A tag no landed render re-pointed (a render outside safeRerenderAll())
|
|
20232
|
+
// still names the detached surface: the id lookup is all there is then.
|
|
20233
|
+
const remapped = waitTag && waitTag.surface !== editEl;
|
|
20234
|
+
const liveBlockEl = remapped
|
|
20235
|
+
? (waitTag.surface && waitTag.surface.isConnected ? waitTag.surface.closest('.ed-block') : null)
|
|
20236
|
+
: (blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null);
|
|
17813
20237
|
liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
17814
20238
|
if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
|
|
17815
20239
|
liveEditEl.focus();
|
|
20240
|
+
// v3.13.1 R11: focus() alone puts the caret at the surface's start, not
|
|
20241
|
+
// where the click that brought focus here put it. Measured on base and
|
|
20242
|
+
// head, both engines: edit A, click mid-B, type — the characters landed
|
|
20243
|
+
// at B's start; a drag or double-click selection collapsed. The
|
|
20244
|
+
// selection was read just before the render replaced B (lastRenderSel).
|
|
20245
|
+
if (lastRenderSel && lastRenderSel.surface === editEl) {
|
|
20246
|
+
restoreSelection(liveEditEl, lastRenderSel.anchor, lastRenderSel.focus);
|
|
20247
|
+
}
|
|
20248
|
+
scheduleFlightFlush();
|
|
17816
20249
|
return;
|
|
17817
20250
|
}
|
|
17818
20251
|
startBurst(liveEditEl);
|
|
20252
|
+
scheduleFlightFlush();
|
|
17819
20253
|
});
|
|
17820
20254
|
|
|
17821
20255
|
document.addEventListener('focusout', (e) => {
|
|
@@ -17956,6 +20390,110 @@
|
|
|
17956
20390
|
return currentBurst.history;
|
|
17957
20391
|
}
|
|
17958
20392
|
|
|
20393
|
+
// v3.13.1 T1: a pointer press into a surface during a flight moves the
|
|
20394
|
+
// selection the user means the next held key for (see `flightSelMoved`).
|
|
20395
|
+
// A press anywhere else — the toolbar, the margin — is not one.
|
|
20396
|
+
// Capture phase: this runs before the press moves the selection, which is
|
|
20397
|
+
// what pins the opening run to where it was typed (see pinFirstFlightRun()).
|
|
20398
|
+
document.addEventListener('pointerdown', (e) => {
|
|
20399
|
+
if (!flightActive()) return;
|
|
20400
|
+
pinFirstFlightRun(selectionSnapshotIn(activeFlightSurface()));
|
|
20401
|
+
if (flightSurfaceOf(e.target)) flightSelMoved = true;
|
|
20402
|
+
}, true);
|
|
20403
|
+
|
|
20404
|
+
// v3.13.1 T1: the input half of the commit-in-flight guard (see
|
|
20405
|
+
// `commitFlight`). Registered ahead of the native-format listener below,
|
|
20406
|
+
// both in the capture phase, so a held keystroke never reaches it.
|
|
20407
|
+
//
|
|
20408
|
+
// `isComposing` insertText is skipped: an engine that reports a
|
|
20409
|
+
// composition's commit that way also reports it to compositionend below,
|
|
20410
|
+
// and the text must be held once.
|
|
20411
|
+
function flightSurfaceOfEvent(e) {
|
|
20412
|
+
return flightActive() ? flightSurfaceOf(e.target) : null;
|
|
20413
|
+
}
|
|
20414
|
+
// The text compositionend just held. An engine that inserts a
|
|
20415
|
+
// composition's commit AFTER compositionend (WebKit's
|
|
20416
|
+
// insertFromComposition) would put it in the DOM a second time; that
|
|
20417
|
+
// insertion is cancelled, or taken back out on its input event when it
|
|
20418
|
+
// cannot be cancelled. Cleared on the next task: the follow-up arrives in
|
|
20419
|
+
// the same one or not at all.
|
|
20420
|
+
let composedHeld = null;
|
|
20421
|
+
let composedTakeBack = null;
|
|
20422
|
+
document.addEventListener('beforeinput', (e) => {
|
|
20423
|
+
if (composedHeld !== null && e.data === composedHeld &&
|
|
20424
|
+
(e.inputType === 'insertFromComposition' || e.inputType === 'insertText')) {
|
|
20425
|
+
composedHeld = null;
|
|
20426
|
+
if (e.cancelable) e.preventDefault(); else composedTakeBack = e.data;
|
|
20427
|
+
return;
|
|
20428
|
+
}
|
|
20429
|
+
const surface = flightSurfaceOfEvent(e);
|
|
20430
|
+
if (!surface) return;
|
|
20431
|
+
const t = e.inputType;
|
|
20432
|
+
if ((t === 'insertText' || t === 'insertReplacementText') && !e.isComposing) {
|
|
20433
|
+
// insertReplacementText (autocorrect, spellcheck) carries its text in
|
|
20434
|
+
// dataTransfer in some engines and in `data` in others.
|
|
20435
|
+
const data = e.data != null ? e.data
|
|
20436
|
+
: (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
|
|
20437
|
+
e.preventDefault();
|
|
20438
|
+
if (data) holdFlightEntry(surface, { kind: 'text', data });
|
|
20439
|
+
return;
|
|
20440
|
+
}
|
|
20441
|
+
// A deletion that did not come from a held key (the keyboard ones are
|
|
20442
|
+
// held at keydown — WebKit fires no beforeinput at all for Backspace on
|
|
20443
|
+
// an empty surface, measured): a context-menu delete, say.
|
|
20444
|
+
if (/^delete/.test(t) && t !== 'deleteByCut' && t !== 'deleteByDrag' && t !== 'deleteCompositionText') {
|
|
20445
|
+
e.preventDefault();
|
|
20446
|
+
holdFlightEntry(surface, { kind: 'delete', inputType: t });
|
|
20447
|
+
}
|
|
20448
|
+
}, true);
|
|
20449
|
+
document.addEventListener('input', (e) => {
|
|
20450
|
+
if (composedTakeBack !== null && e.data === composedTakeBack) takeBackComposedText(e.data);
|
|
20451
|
+
composedTakeBack = null;
|
|
20452
|
+
}, true);
|
|
20453
|
+
// Known limit: a composition still open when the flight ends is left to
|
|
20454
|
+
// the browser — its compositionend arrives with no flight to hold it, and
|
|
20455
|
+
// if the render replaced the surface mid-composition, what the engine does
|
|
20456
|
+
// with the rest of it is its own.
|
|
20457
|
+
document.addEventListener('compositionend', (e) => {
|
|
20458
|
+
const surface = flightSurfaceOfEvent(e);
|
|
20459
|
+
if (!surface || !e.data) return;
|
|
20460
|
+
takeBackComposedText(e.data);
|
|
20461
|
+
holdFlightEntry(surface, { kind: 'text', data: e.data });
|
|
20462
|
+
composedHeld = e.data;
|
|
20463
|
+
setTimeout(() => { composedHeld = null; }, 0);
|
|
20464
|
+
}, true);
|
|
20465
|
+
|
|
20466
|
+
// v3.14.0 T8: input into a focused armed surface that has no burst starts
|
|
20467
|
+
// one first, before the DOM changes, so the burst's baseline is the
|
|
20468
|
+
// surface as it was (ensureBurst()). Final review I3: a native format
|
|
20469
|
+
// chord (Ctrl+B / I / U, NATIVE_FORMAT_INPUT_TYPES) and a drop are input
|
|
20470
|
+
// too — measured before: Escape, select, Ctrl+B showed the bold and the
|
|
20471
|
+
// file kept the plain word. This listener is registered ahead of the
|
|
20472
|
+
// `native-format-pre` one below (both capture phase on document, so they
|
|
20473
|
+
// run in that order), so the burst exists when that one snaps it.
|
|
20474
|
+
document.addEventListener('beforeinput', (e) => {
|
|
20475
|
+
if (e.defaultPrevented) return;
|
|
20476
|
+
const t = e.inputType;
|
|
20477
|
+
if (t !== 'insertText' && t !== 'insertReplacementText' && t !== 'insertFromPaste' &&
|
|
20478
|
+
t !== 'insertLineBreak' && t !== 'insertParagraph' && !/^delete/.test(t) &&
|
|
20479
|
+
!NATIVE_FORMAT_INPUT_TYPES.has(t) && t !== 'insertFromDrop') return;
|
|
20480
|
+
const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed') : null;
|
|
20481
|
+
if (surface) ensureBurst(surface);
|
|
20482
|
+
}, true);
|
|
20483
|
+
// Fix round 1 (I3, R35): an IME composition is input too. Its text arrives
|
|
20484
|
+
// as insertCompositionText, which the list above leaves out, so composed
|
|
20485
|
+
// text after Escape or after Ctrl+S on an untouched surface was never
|
|
20486
|
+
// committed (measured, Chromium via CDP: X, Escape, IME 你 → file
|
|
20487
|
+
// `Alpha.`). The burst opens at compositionstart, before the composition
|
|
20488
|
+
// touches the DOM, so its baseline is the surface as it was; it only opens
|
|
20489
|
+
// a burst, and commits nothing mid-composition. ensureBurst() never opens a
|
|
20490
|
+
// second one, and holds off during a flight (the flight guard owns the
|
|
20491
|
+
// composition then).
|
|
20492
|
+
document.addEventListener('compositionstart', (e) => {
|
|
20493
|
+
const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed') : null;
|
|
20494
|
+
if (surface) ensureBurst(surface);
|
|
20495
|
+
}, true);
|
|
20496
|
+
|
|
17959
20497
|
// Capture phase, matching the phase the ordering above was measured in.
|
|
17960
20498
|
document.addEventListener('beforeinput', (e) => {
|
|
17961
20499
|
if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
|
|
@@ -18022,10 +20560,35 @@
|
|
|
18022
20560
|
// wave field is not — this is here so the boundary is one predicate rather
|
|
18023
20561
|
// than a property of which targets those two selectors happen to match.
|
|
18024
20562
|
if (waveEditorIsModal()) return;
|
|
18025
|
-
|
|
18026
|
-
|
|
20563
|
+
// v3.13.1 T1 fix round 1: a paste while a commit is in flight is held like
|
|
20564
|
+
// typing (measured before: lost outright) and replayed through
|
|
20565
|
+
// routePaste(), the path a live paste takes. The clipboard is readable
|
|
20566
|
+
// only during the event, so its flavours are copied out here.
|
|
20567
|
+
const flightSurface = flightSurfaceOfEvent(e);
|
|
20568
|
+
if (flightSurface) {
|
|
18027
20569
|
e.preventDefault();
|
|
18028
|
-
|
|
20570
|
+
holdFlightEntry(flightSurface, { kind: 'paste',
|
|
20571
|
+
dt: snapshotClipboard(e.clipboardData || window.clipboardData), plainOnly: plainPasteArmed });
|
|
20572
|
+
plainPasteArmed = false;
|
|
20573
|
+
return;
|
|
20574
|
+
}
|
|
20575
|
+
const target = e.target && e.target.closest &&
|
|
20576
|
+
(e.target.closest('.ed-wys-cell') || e.target.closest('.ed-wys-armed'));
|
|
20577
|
+
if (!target) return;
|
|
20578
|
+
e.preventDefault();
|
|
20579
|
+
routePaste(target, e.clipboardData || window.clipboardData);
|
|
20580
|
+
});
|
|
20581
|
+
function snapshotClipboard(dt) {
|
|
20582
|
+
const data = {};
|
|
20583
|
+
['text/plain', 'text/html'].forEach((k) => {
|
|
20584
|
+
try { data[k] = dt ? dt.getData(k) || '' : ''; } catch (err) { data[k] = ''; }
|
|
20585
|
+
});
|
|
20586
|
+
const files = dt && dt.files ? Array.prototype.slice.call(dt.files) : [];
|
|
20587
|
+
return { files, types: Object.keys(data).filter((k) => data[k]), getData: (k) => data[k] || '' };
|
|
20588
|
+
}
|
|
20589
|
+
function routePaste(target, dt) {
|
|
20590
|
+
const cellEl = target.closest('.ed-wys-cell');
|
|
20591
|
+
if (cellEl) {
|
|
18029
20592
|
const grid = dt ? clipboardGrid(dt) : null;
|
|
18030
20593
|
if (grid && pasteGridIntoTable(cellEl, grid)) return;
|
|
18031
20594
|
snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
|
|
@@ -18033,10 +20596,8 @@
|
|
|
18033
20596
|
snapBurstIfActive(cellEl.closest('table'), 'paste');
|
|
18034
20597
|
return;
|
|
18035
20598
|
}
|
|
18036
|
-
const editEl =
|
|
20599
|
+
const editEl = target.closest('.ed-wys-armed');
|
|
18037
20600
|
if (!editEl) return;
|
|
18038
|
-
e.preventDefault();
|
|
18039
|
-
const dt = e.clipboardData || window.clipboardData;
|
|
18040
20601
|
// v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
|
|
18041
20602
|
// (text/html -> markdown, an image, or plain text) and this routes the
|
|
18042
20603
|
// answer. The TABLE-CELL branch above deliberately keeps its plain-text
|
|
@@ -18044,7 +20605,7 @@
|
|
|
18044
20605
|
// pasted into one cell would be a nested structure the serializer has no
|
|
18045
20606
|
// way to represent.
|
|
18046
20607
|
handleArmedPaste(dt, editEl);
|
|
18047
|
-
}
|
|
20608
|
+
}
|
|
18048
20609
|
|
|
18049
20610
|
// Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
|
|
18050
20611
|
// mousemove listener (never a per-block/per-boundary listener) drives
|
|
@@ -19787,4 +22348,9 @@
|
|
|
19787
22348
|
// selToolbar, …) is initialised and the bar's first deriveState() sees the
|
|
19788
22349
|
// armed document rather than an empty one.
|
|
19789
22350
|
mountToolbar();
|
|
22351
|
+
// v3.14.0 T4: after the bar exists, so its focusin tracking sees the
|
|
22352
|
+
// empty document's transient and reports a paragraph.
|
|
22353
|
+
wireEmptyMark();
|
|
22354
|
+
wireClickBelow();
|
|
22355
|
+
ensureEmptyDocTransient();
|
|
19790
22356
|
})();
|