@helping-ai-workflow/md2doc 3.13.1 → 3.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -346
- package/lib/editor/client.js +2141 -220
- package/lib/editor/inline-md.js +21 -0
- package/lib/editor/toolbar-model.js +11 -1
- package/lib/md2doc.js +31 -10
- package/lib/theme/tokens.js +1 -1
- 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
|
|
@@ -1128,6 +1203,9 @@
|
|
|
1128
1203
|
// open with its edit intact, banner already shown, caller must abandon
|
|
1129
1204
|
// whatever it was about to do).
|
|
1130
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;
|
|
1131
1209
|
if (currentBurst) {
|
|
1132
1210
|
const ok = await resolveBurst();
|
|
1133
1211
|
if (!ok) return false;
|
|
@@ -1192,7 +1270,11 @@
|
|
|
1192
1270
|
// The distance was a second record of "are we standing where we saved";
|
|
1193
1271
|
// `UndoStack` now answers that question itself, and a boolean has no zero
|
|
1194
1272
|
// to walk through. See lineops.js's own comment for the full story.
|
|
1195
|
-
|
|
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;
|
|
1196
1278
|
}
|
|
1197
1279
|
|
|
1198
1280
|
function setDirty() {
|
|
@@ -1876,6 +1958,9 @@
|
|
|
1876
1958
|
// 要為一次沒上畫面的編輯升起警告」這件事寫在這裡,而不是留給讀者去讀
|
|
1877
1959
|
// 它們的本體。
|
|
1878
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();
|
|
1879
1964
|
lastRenderLines = lines.slice();
|
|
1880
1965
|
lastRenderEndedUnclosed = nowUnclosed;
|
|
1881
1966
|
// 升 banner 排在套用之後:套用是把新的 DOM 放上畫面那一步,警告的是使用
|
|
@@ -2528,6 +2613,9 @@
|
|
|
2528
2613
|
// insertBlockBelow() rerenderAll() awaits, so it can never clobber a
|
|
2529
2614
|
// fresh assignment.
|
|
2530
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;
|
|
2531
2619
|
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
2532
2620
|
// WYSIWYG burst — whatever block had one open a moment ago was just
|
|
2533
2621
|
// detached by the innerHTML replacement above, so its own focusout
|
|
@@ -2737,9 +2825,15 @@
|
|
|
2737
2825
|
// * A "positioned" entry is the first one held after a pointer press into
|
|
2738
2826
|
// a surface during the flight: it carries a tag (surface, block id,
|
|
2739
2827
|
// table cell index) and the full selection (anchor and focus offsets),
|
|
2740
|
-
// restored before it replays.
|
|
2741
|
-
//
|
|
2742
|
-
//
|
|
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
|
|
2743
2837
|
// replaces its surface: by the committed run's own line map for an item
|
|
2744
2838
|
// of a list run the commit rewrote (an item the split pushed down a
|
|
2745
2839
|
// line, the provisional item), otherwise by its block's start line,
|
|
@@ -2851,6 +2945,7 @@
|
|
|
2851
2945
|
cellIndex: isCell && blockEl
|
|
2852
2946
|
? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
|
|
2853
2947
|
anchor: snap.anchor, focus: snap.focus,
|
|
2948
|
+
logAt: flightLogLength(snap.surface),
|
|
2854
2949
|
};
|
|
2855
2950
|
}
|
|
2856
2951
|
|
|
@@ -2870,24 +2965,44 @@
|
|
|
2870
2965
|
return map;
|
|
2871
2966
|
}
|
|
2872
2967
|
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
// the
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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;
|
|
2885
2999
|
}
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
if (
|
|
2890
|
-
|
|
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;
|
|
2891
3006
|
}
|
|
2892
3007
|
|
|
2893
3008
|
// Pins the run of continuation entries that opened this flight (if it is
|
|
@@ -2905,7 +3020,7 @@
|
|
|
2905
3020
|
entry.hint = flightTagOf(selectionSnapshotIn(surface));
|
|
2906
3021
|
if (flightSelMoved) {
|
|
2907
3022
|
flightSelMoved = false;
|
|
2908
|
-
entry.pos =
|
|
3023
|
+
entry.pos = flightTagOf(selectionSnapshotIn(surface));
|
|
2909
3024
|
}
|
|
2910
3025
|
const last = flightBuffer[flightBuffer.length - 1];
|
|
2911
3026
|
if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
|
|
@@ -2919,7 +3034,7 @@
|
|
|
2919
3034
|
function reanchorFlightBuffer(before, editRange, lineMap) {
|
|
2920
3035
|
if (flightSelMoved && lastRenderSel) {
|
|
2921
3036
|
flightSelMoved = false;
|
|
2922
|
-
flightBuffer.push({ kind: 'focus', pos:
|
|
3037
|
+
flightBuffer.push({ kind: 'focus', pos: flightTagOf(lastRenderSel) });
|
|
2923
3038
|
}
|
|
2924
3039
|
flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
|
|
2925
3040
|
? flightTagOf(lastRenderSel) : null;
|
|
@@ -2942,7 +3057,23 @@
|
|
|
2942
3057
|
if (editRange && line > editRange.endLine) line += editRange.delta;
|
|
2943
3058
|
}
|
|
2944
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
|
+
}
|
|
2945
3075
|
pos.surface = null;
|
|
3076
|
+
pos.logAt = 0;
|
|
2946
3077
|
if (line === null) return;
|
|
2947
3078
|
const nb = blocks.find((b) => b.startLine === line);
|
|
2948
3079
|
const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
|
|
@@ -2952,11 +3083,19 @@
|
|
|
2952
3083
|
: blockContentEl(nEl);
|
|
2953
3084
|
if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
|
|
2954
3085
|
pos.surface = surf;
|
|
3086
|
+
pos.logAt = flightLogLength(surf);
|
|
2955
3087
|
pos.blockId = String(nb.id);
|
|
2956
3088
|
};
|
|
2957
3089
|
flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
|
|
2958
3090
|
remap(flightFallback);
|
|
3091
|
+
focusinWaitTags.forEach(remap);
|
|
2959
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 = [];
|
|
2960
3099
|
|
|
2961
3100
|
// A deletion chord's input type, and a caret chord's movement, per
|
|
2962
3101
|
// platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
|
|
@@ -2987,7 +3126,7 @@
|
|
|
2987
3126
|
return null;
|
|
2988
3127
|
}
|
|
2989
3128
|
function isHeldTyping(en) {
|
|
2990
|
-
return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' ||
|
|
3129
|
+
return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' || en.kind === 'format' ||
|
|
2991
3130
|
(en.kind === 'key' && !caretMoveForKey(en));
|
|
2992
3131
|
}
|
|
2993
3132
|
function dropHeldTyping() {
|
|
@@ -3047,6 +3186,11 @@
|
|
|
3047
3186
|
routePaste(target, item.dt);
|
|
3048
3187
|
} else if (item.kind === 'delete') {
|
|
3049
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');
|
|
3050
3194
|
} else if (item.kind === 'key') {
|
|
3051
3195
|
const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
|
|
3052
3196
|
ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
|
|
@@ -3076,14 +3220,16 @@
|
|
|
3076
3220
|
target.focus();
|
|
3077
3221
|
if (commitFlight || switching) break;
|
|
3078
3222
|
}
|
|
3079
|
-
restoreSelection(target, item.pos
|
|
3223
|
+
restoreSelection(target, mapOffsetThroughLog(item.pos, item.pos.anchor),
|
|
3224
|
+
mapOffsetThroughLog(item.pos, item.pos.focus));
|
|
3080
3225
|
} else {
|
|
3081
3226
|
target = activeFlightSurface();
|
|
3082
3227
|
const fb = flightFallback;
|
|
3083
3228
|
if (!target && fb && fb.surface && fb.surface.isConnected) {
|
|
3084
3229
|
fb.surface.focus();
|
|
3085
3230
|
if (commitFlight || switching) break;
|
|
3086
|
-
restoreSelection(fb.surface, fb
|
|
3231
|
+
restoreSelection(fb.surface, mapOffsetThroughLog(fb, fb.anchor),
|
|
3232
|
+
mapOffsetThroughLog(fb, fb.focus));
|
|
3087
3233
|
flightFallback = null;
|
|
3088
3234
|
target = activeFlightSurface();
|
|
3089
3235
|
}
|
|
@@ -3091,10 +3237,26 @@
|
|
|
3091
3237
|
}
|
|
3092
3238
|
flightBuffer.shift();
|
|
3093
3239
|
if (item.kind !== 'focus') {
|
|
3240
|
+
const pre = selectionSnapshotIn(target);
|
|
3241
|
+
const len0 = surfaceTextLength(target);
|
|
3094
3242
|
flightReplaying = true;
|
|
3095
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
|
+
}
|
|
3096
3257
|
}
|
|
3097
3258
|
}
|
|
3259
|
+
if (!flightBuffer.length) flightEditLog.clear();
|
|
3098
3260
|
if (!flightBuffer.length && !commitFlight) flightFallback = null;
|
|
3099
3261
|
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
3100
3262
|
}
|
|
@@ -3366,6 +3528,9 @@
|
|
|
3366
3528
|
// v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
|
|
3367
3529
|
// re-derives all three of `blockId`/`startLine`/`endLine` when this
|
|
3368
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;
|
|
3369
3534
|
let blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
3370
3535
|
const block = blocks.find((b) => b.id === blockId);
|
|
3371
3536
|
if (!block) return;
|
|
@@ -4572,9 +4737,40 @@
|
|
|
4572
4737
|
// `Alpha <em><br></em> bold text here.` — dropping before the insert would
|
|
4573
4738
|
// have taken that <em> and the caret's new home with it.
|
|
4574
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
|
+
}
|
|
4575
4758
|
sel.removeAllRanges();
|
|
4576
4759
|
sel.addRange(range);
|
|
4577
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
|
+
}
|
|
4578
4774
|
|
|
4579
4775
|
// Heading ± buttons on the bar: a pure source-level transform (just the
|
|
4580
4776
|
// leading `#` run) via the SAME commitEdit()/replaceLines() pipeline as
|
|
@@ -4629,6 +4825,7 @@
|
|
|
4629
4825
|
|
|
4630
4826
|
async function changeHeadingDepthBody(blockEl, delta, anchor) {
|
|
4631
4827
|
if (!blockEl) return;
|
|
4828
|
+
if (blockEl.getAttribute('data-block-id') === null) return; // R2: id-less → not block 0
|
|
4632
4829
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
4633
4830
|
// Commits (never discards) whatever burst/editor is open first — same
|
|
4634
4831
|
// precondition undo()/redo() use below, so this never races a concurrent
|
|
@@ -4752,6 +4949,9 @@
|
|
|
4752
4949
|
if (blockType === 'heading') stripHeadingAnchor(editEl);
|
|
4753
4950
|
editEl.setAttribute('contenteditable', 'true');
|
|
4754
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);
|
|
4755
4955
|
} else if (blockType === 'li') {
|
|
4756
4956
|
// Task 6 (Phase 4): per-li arming. Each
|
|
4757
4957
|
// `.ed-block[data-block-type="li"]` is armed independently: only its
|
|
@@ -5378,19 +5578,11 @@
|
|
|
5378
5578
|
// this uses the bare marker `-` instead — verified to lex as `list` with
|
|
5379
5579
|
// one empty `<li>`.
|
|
5380
5580
|
//
|
|
5381
|
-
//
|
|
5382
|
-
//
|
|
5383
|
-
//
|
|
5384
|
-
//
|
|
5385
|
-
//
|
|
5386
|
-
// locate the inserted block in the server's recomputed block list.
|
|
5387
|
-
// U+200B (zero-width space) is real, non-whitespace-per-`\s` text that
|
|
5388
|
-
// marked DOES lex as its own paragraph, and renders as `<p></p>` —
|
|
5389
|
-
// visually empty. focusInsertedBlock() below selects that single
|
|
5390
|
-
// character so an immediate keystroke replaces it, matching the "empty
|
|
5391
|
-
// paragraph to type into" intent; if the user commits without typing at
|
|
5392
|
-
// all, the ZWSP is what ends up on disk (a known, documented trade-off —
|
|
5393
|
-
// 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.
|
|
5394
5586
|
//
|
|
5395
5587
|
// 程式碼: a bare two-line fence pair (```/```, nothing between) is what
|
|
5396
5588
|
// "fence pair with empty body" reads as most literally, and DOES lex as
|
|
@@ -5404,7 +5596,6 @@
|
|
|
5404
5596
|
// browser probe). A three-line fence with one blank line between them
|
|
5405
5597
|
// (still `text: ''` per marked — verified) gives that line to land on.
|
|
5406
5598
|
const BLOCK_SKELETONS = {
|
|
5407
|
-
paragraph: [''],
|
|
5408
5599
|
heading: ['## '],
|
|
5409
5600
|
list: ['-'],
|
|
5410
5601
|
table: ['| A | B |', '|---|---|', '| | |'],
|
|
@@ -5422,8 +5613,8 @@
|
|
|
5422
5613
|
// freshly-inserted, placeholder-only block) so the user's very first
|
|
5423
5614
|
// keystroke replaces the placeholder instead of being inserted next to
|
|
5424
5615
|
// it. A no-op-equivalent (nothing to select) on the genuinely-empty
|
|
5425
|
-
// heading/list/table skeletons
|
|
5426
|
-
// skeleton
|
|
5616
|
+
// heading/list/table skeletons, which are all that reach it since the
|
|
5617
|
+
// paragraph skeleton went away (v3.14.0 T1).
|
|
5427
5618
|
function focusAndSelectAll(el) {
|
|
5428
5619
|
el.focus();
|
|
5429
5620
|
const range = document.createRange();
|
|
@@ -5548,6 +5739,16 @@
|
|
|
5548
5739
|
// parameter and not a second insertion path.
|
|
5549
5740
|
async function insertBlockBelow(blockEl, kind, opts) {
|
|
5550
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;
|
|
5551
5752
|
// T7: captured BEFORE switchAwayFrom(), because that is what can renumber
|
|
5552
5753
|
// the ids — see captureBlockIdentity()'s comment.
|
|
5553
5754
|
const identity = captureBlockIdentity(blockEl);
|
|
@@ -5599,7 +5800,8 @@
|
|
|
5599
5800
|
// 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
|
|
5600
5801
|
// non-list kind safe here. Measured on ['# Doc','','- alpha',
|
|
5601
5802
|
// ' - child',' - grand','']: anchored on `child`,
|
|
5602
|
-
// 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
|
|
5603
5805
|
// '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
|
|
5604
5806
|
// marked lexes ' - grand' after a paragraph as an INDENTED CODE
|
|
5605
5807
|
// BLOCK — the grandchild's content is gone. Anchored on the end of the
|
|
@@ -5685,7 +5887,7 @@
|
|
|
5685
5887
|
// pasted paragraph it would silently delete real content the moment the
|
|
5686
5888
|
// user clicked elsewhere without typing. focusAndSelectAll() is wrong for
|
|
5687
5889
|
// the same reason: it selects the block's whole content so the first
|
|
5688
|
-
// keystroke replaces it, which is what a
|
|
5890
|
+
// keystroke replaces it, which is what a placeholder skeleton wants and what
|
|
5689
5891
|
// a just-pasted paragraph very much does not.
|
|
5690
5892
|
//
|
|
5691
5893
|
// 'line' is excluded for the same reason from the other direction: both
|
|
@@ -5739,6 +5941,1396 @@
|
|
|
5739
5941
|
}
|
|
5740
5942
|
|
|
5741
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
|
+
|
|
5742
7334
|
// S2 Task 7 — the li half of +. The new item is spliced into the run's own
|
|
5743
7335
|
// span and the WHOLE span is re-serialized over the run's line range: one
|
|
5744
7336
|
// commitRangeEdit, therefore one undo op, no leading blank line (so the run
|
|
@@ -6129,12 +7721,12 @@
|
|
|
6129
7721
|
// never reported, never tested for duplicate/delete) — widening past
|
|
6130
7722
|
// convertBlockViaMenu() to the other three callers remains a separate
|
|
6131
7723
|
// decision this task did not make and should not make silently.
|
|
6132
|
-
function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
|
|
7724
|
+
function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue, caretToEnd) {
|
|
6133
7725
|
// 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
|
|
6134
7726
|
// 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
|
|
6135
7727
|
if (blockSelection) return;
|
|
6136
7728
|
if (focusLine != null) {
|
|
6137
|
-
focusBlockAtLine(focusLine,
|
|
7729
|
+
focusBlockAtLine(focusLine, caretToEnd !== false); // caretToEnd unless asked otherwise
|
|
6138
7730
|
// 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
|
|
6139
7731
|
// no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
|
|
6140
7732
|
reaimToolbarBlockAtLine(focusLine);
|
|
@@ -6254,7 +7846,10 @@
|
|
|
6254
7846
|
// The body returns the operated block's post-commit start line, or undefined
|
|
6255
7847
|
// when it refused or wrote nothing — `anchor.line` is what covers the
|
|
6256
7848
|
// second case (see restoreAfterStructuralOp()).
|
|
6257
|
-
|
|
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) {
|
|
6258
7853
|
let focusLine = null;
|
|
6259
7854
|
const anchor = {};
|
|
6260
7855
|
try {
|
|
@@ -6270,7 +7865,7 @@
|
|
|
6270
7865
|
// Task 9 review I4: `true` — this is the ONE call site backlog #6's
|
|
6271
7866
|
// raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
|
|
6272
7867
|
// own `allowRawEditRescue` comment.
|
|
6273
|
-
restoreAfterStructuralOp(focusLine, anchor.line, true);
|
|
7868
|
+
restoreAfterStructuralOp(focusLine, anchor.line, true, !(opts && opts.caretToEnd === false));
|
|
6274
7869
|
}
|
|
6275
7870
|
}
|
|
6276
7871
|
|
|
@@ -6413,7 +8008,8 @@
|
|
|
6413
8008
|
// block the user never pointed at.
|
|
6414
8009
|
function reresolveBlockElAfterSelfCommit(identity) {
|
|
6415
8010
|
if (!identity) return null;
|
|
6416
|
-
const
|
|
8011
|
+
const startLine = identityStartLineNow(identity);
|
|
8012
|
+
const at = blocks.find((b) => b.startLine === startLine);
|
|
6417
8013
|
if (!at || at.type !== identity.type) return null;
|
|
6418
8014
|
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
6419
8015
|
}
|
|
@@ -7774,8 +9370,16 @@
|
|
|
7774
9370
|
function burstBaselineHtml(editEl) {
|
|
7775
9371
|
const html = editEl.innerHTML;
|
|
7776
9372
|
if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1 &&
|
|
7777
|
-
html.indexOf('ed-cell-range') === -1) return html;
|
|
9373
|
+
html.indexOf('ed-cell-range') === -1 && html.indexOf('search-hit') === -1) return html;
|
|
7778
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
|
+
});
|
|
7779
9383
|
const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging, .ed-cell-range');
|
|
7780
9384
|
Array.prototype.forEach.call(marked, (el) => {
|
|
7781
9385
|
el.classList.remove('ed-te-hl');
|
|
@@ -7795,6 +9399,32 @@
|
|
|
7795
9399
|
dropDiscardedRawEdit();
|
|
7796
9400
|
const blockEl = editEl.closest('.ed-block');
|
|
7797
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;
|
|
7798
9428
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
7799
9429
|
const block = blocks.find((b) => b.id === blockId);
|
|
7800
9430
|
if (!block) return;
|
|
@@ -7854,6 +9484,9 @@
|
|
|
7854
9484
|
// ordinal into.
|
|
7855
9485
|
cancelBlockDrag();
|
|
7856
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);
|
|
7857
9490
|
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
7858
9491
|
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
7859
9492
|
// OWN first burst resolves — one way (a real edit, below) or the
|
|
@@ -8100,9 +9733,9 @@
|
|
|
8100
9733
|
// the paragraph into that block. A heading's text sits after its own
|
|
8101
9734
|
// `# ` and needs none; a table's rows START with `|`, so its markdown
|
|
8102
9735
|
// must never reach the escape — hence the blockType test, not just depth.
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
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);
|
|
8106
9739
|
// S1 removed the pre-per-li "a whole-list burst that serialized to ''"
|
|
8107
9740
|
// branch that lived here alongside the dead 'list' serializer arm above.
|
|
8108
9741
|
// The per-li equivalent — a run whose every item was removed — is handled
|
|
@@ -8657,11 +10290,30 @@
|
|
|
8657
10290
|
if (raw === null) return null;
|
|
8658
10291
|
const b = blocks.find((x) => x.id === Number(raw));
|
|
8659
10292
|
if (!b) return null;
|
|
8660
|
-
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;
|
|
8661
10312
|
}
|
|
8662
10313
|
function reresolveBlockEl(identity) {
|
|
8663
10314
|
if (!identity) return null;
|
|
8664
|
-
const
|
|
10315
|
+
const startLine = identityStartLineNow(identity);
|
|
10316
|
+
const at = blocks.find((b) => b.startLine === startLine);
|
|
8665
10317
|
if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
|
|
8666
10318
|
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
8667
10319
|
}
|
|
@@ -8863,12 +10515,59 @@
|
|
|
8863
10515
|
// successful render (any later commit, undo, or redo) re-derives the DOM
|
|
8864
10516
|
// from `lines`. Same convention as insertBlockBelow() /
|
|
8865
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);
|
|
8866
10526
|
return false;
|
|
8867
10527
|
}
|
|
8868
10528
|
if (focusStartLine != null) focusBlockAtLine(focusStartLine, caretToEnd);
|
|
8869
10529
|
return true;
|
|
8870
10530
|
}
|
|
8871
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
|
+
|
|
8872
10571
|
// The line `targetBlock`'s own marker will occupy once the run span it belongs
|
|
8873
10572
|
// to is committed by commitListStructure() above.
|
|
8874
10573
|
//
|
|
@@ -9052,6 +10751,34 @@
|
|
|
9052
10751
|
// this unconditionally qualifies as "abandoned" (no separate
|
|
9053
10752
|
// unchanged-check needed here, unlike resolveBurst()'s branch, where a
|
|
9054
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
|
+
}
|
|
9055
10782
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
9056
10783
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
9057
10784
|
// T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
|
|
@@ -9092,7 +10819,13 @@
|
|
|
9092
10819
|
};
|
|
9093
10820
|
currentBurst = null;
|
|
9094
10821
|
resetSelToolbarState();
|
|
9095
|
-
|
|
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);
|
|
9096
10829
|
}
|
|
9097
10830
|
|
|
9098
10831
|
// The table twin of the stash above — a table burst's editEl is the whole
|
|
@@ -9147,6 +10880,11 @@
|
|
|
9147
10880
|
: d.editEl;
|
|
9148
10881
|
if (!entry) { d.history.dispose(); return false; }
|
|
9149
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
|
+
}
|
|
9150
10888
|
if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
|
|
9151
10889
|
currentBurst.history.dispose();
|
|
9152
10890
|
currentBurst.history = d.history;
|
|
@@ -9244,9 +10982,47 @@
|
|
|
9244
10982
|
const state = burst.history.undo();
|
|
9245
10983
|
if (state !== null) {
|
|
9246
10984
|
editEl.innerHTML = state;
|
|
10985
|
+
syncEmptyMark(editEl);
|
|
9247
10986
|
placeCaretAtEnd(editEl); // best-effort — see the file-level caret-quirk note
|
|
9248
10987
|
return;
|
|
9249
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
|
+
}
|
|
9250
11026
|
// §10-gap fix (review): if this burst is an untouched pristine insert,
|
|
9251
11027
|
// switchAwayFrom() below will itself auto-remove the block (resolveBurst()'s
|
|
9252
11028
|
// pristineInsert branch) — that auto-remove IS the undo the user just
|
|
@@ -9268,6 +11044,7 @@
|
|
|
9268
11044
|
const state = burst.history.redo();
|
|
9269
11045
|
if (state !== null) {
|
|
9270
11046
|
editEl.innerHTML = state;
|
|
11047
|
+
syncEmptyMark(editEl);
|
|
9271
11048
|
placeCaretAtEnd(editEl);
|
|
9272
11049
|
return;
|
|
9273
11050
|
}
|
|
@@ -9317,6 +11094,19 @@
|
|
|
9317
11094
|
//
|
|
9318
11095
|
// `reason` is metadata only — createBurstHistory().snap() does not read it
|
|
9319
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
|
+
|
|
9320
11110
|
function snapBurstIfActive(root, reason) {
|
|
9321
11111
|
if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
|
|
9322
11112
|
currentBurst.history.snap(reason);
|
|
@@ -9329,6 +11119,13 @@
|
|
|
9329
11119
|
// wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
|
|
9330
11120
|
// it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
|
|
9331
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
|
+
}
|
|
9332
11129
|
if (!currentBurst || currentBurst.editEl !== editEl) {
|
|
9333
11130
|
// v2.11.1 acceptance, escape class B. This bail is reachable with the
|
|
9334
11131
|
// surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
|
|
@@ -9381,6 +11178,11 @@
|
|
|
9381
11178
|
if (currentBurst.blockType === 'li') {
|
|
9382
11179
|
if (handleLiKeydown(e, editEl)) return;
|
|
9383
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;
|
|
9384
11186
|
// Task 6 — spec §3.5's other two rows. Tab is CONSUMED here: the
|
|
9385
11187
|
// alternative is not "nothing happens", it is the browser's own focus
|
|
9386
11188
|
// traversal walking the caret out of the document body, which is both a
|
|
@@ -9422,13 +11224,19 @@
|
|
|
9422
11224
|
return;
|
|
9423
11225
|
}
|
|
9424
11226
|
if (e.key === 'Enter') {
|
|
9425
|
-
e.preventDefault();
|
|
9426
11227
|
if (e.shiftKey) {
|
|
11228
|
+
e.preventDefault();
|
|
9427
11229
|
snapBurstIfActive(editEl, 'br-pre');
|
|
9428
11230
|
insertBrAtCaret();
|
|
9429
11231
|
snapBurstIfActive(editEl, 'br');
|
|
9430
|
-
} 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();
|
|
9431
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);
|
|
9432
11240
|
}
|
|
9433
11241
|
return;
|
|
9434
11242
|
}
|
|
@@ -9609,12 +11417,18 @@
|
|
|
9609
11417
|
// here, so that handler's `if (currentBurst) return;` returns.
|
|
9610
11418
|
function armProvisionalLi(newLi) {
|
|
9611
11419
|
const textEl = liTextEl(newLi);
|
|
9612
|
-
if (
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
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();
|
|
9616
11430
|
const range = document.createRange();
|
|
9617
|
-
range.selectNodeContents(
|
|
11431
|
+
range.selectNodeContents(el);
|
|
9618
11432
|
range.collapse(true);
|
|
9619
11433
|
const sel = window.getSelection();
|
|
9620
11434
|
sel.removeAllRanges();
|
|
@@ -10255,18 +12069,15 @@
|
|
|
10255
12069
|
|
|
10256
12070
|
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
10257
12071
|
// the block to a paragraph". Markdown cannot persist an EMPTY paragraph, so
|
|
10258
|
-
//
|
|
10259
|
-
//
|
|
10260
|
-
//
|
|
10261
|
-
//
|
|
10262
|
-
//
|
|
10263
|
-
//
|
|
10264
|
-
//
|
|
10265
|
-
//
|
|
10266
|
-
//
|
|
10267
|
-
// single undo op. Observed granularity (asserted in
|
|
10268
|
-
// test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
|
|
10269
|
-
// 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()).
|
|
10270
12081
|
async function convertEmptyTopLevelLiToParagraph(runEls, li) {
|
|
10271
12082
|
// Both captured BEFORE the mutation. The range, because removing the run's
|
|
10272
12083
|
// last item leaves commitListStructure() nothing to derive it from. The
|
|
@@ -10309,20 +12120,136 @@
|
|
|
10309
12120
|
const ok = await commitListStructure(survivors, null, false,
|
|
10310
12121
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
10311
12122
|
if (!ok) return;
|
|
10312
|
-
// Nothing precedes the removal point (the list opened the document):
|
|
10313
|
-
//
|
|
10314
|
-
//
|
|
10315
|
-
|
|
10316
|
-
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; }
|
|
10317
12127
|
const anchorEl = blockElAtLine(precedingBlock.startLine);
|
|
10318
12128
|
if (!anchorEl) return;
|
|
10319
|
-
|
|
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;
|
|
10320
12242
|
}
|
|
10321
12243
|
|
|
10322
12244
|
// Returns true when the key was CONSUMED (Enter/Tab, incl. their no-op
|
|
10323
12245
|
// outcomes); false lets handleBurstKeydown()'s shared Escape / Ctrl+Z /
|
|
10324
12246
|
// Ctrl+Y branches run unchanged.
|
|
10325
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);
|
|
10326
12253
|
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
10327
12254
|
e.preventDefault();
|
|
10328
12255
|
// T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
|
|
@@ -10351,65 +12278,7 @@
|
|
|
10351
12278
|
let run = listRunOf(li);
|
|
10352
12279
|
if (!run.length) return true;
|
|
10353
12280
|
|
|
10354
|
-
if (e.key === 'Tab')
|
|
10355
|
-
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
10356
|
-
// a hard-wrapped item is allowed to be the target here — see
|
|
10357
|
-
// listRunSupportsStructuralEdit()'s deviation note for the measurement.
|
|
10358
|
-
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
10359
|
-
refuseStructuralListEdit(); return true;
|
|
10360
|
-
}
|
|
10361
|
-
// Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
|
|
10362
|
-
// children keep their indent and become its siblings. Shift+Tab:
|
|
10363
|
-
// outdentListItem() raises it one level, takes its own subtree with it,
|
|
10364
|
-
// and adopts its former following same-level siblings. Both return false
|
|
10365
|
-
// at their respective boundary (no previous sibling / already top level),
|
|
10366
|
-
// which is a complete no-op — nothing mutated, nothing committed, file
|
|
10367
|
-
// byte-identical.
|
|
10368
|
-
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
10369
|
-
// INERT here: indentListItem()/outdentListItem() are attribute-only in
|
|
10370
|
-
// this flat model (setBlockIndent() on `li` and its subtree — see the
|
|
10371
|
-
// note on outdentListItem() further down this function) and never
|
|
10372
|
-
// detach the focused node, so the synchronous blur/focusout this span
|
|
10373
|
-
// exists to catch never fires, whether or not the flag is set.
|
|
10374
|
-
// Measured: primed a burst on `li`, ran this mutation, and no
|
|
10375
|
-
// focusout fired — `document.activeElement` stayed put and the node
|
|
10376
|
-
// never left the document. Kept for uniformity with the load-bearing
|
|
10377
|
-
// span in convertEmptyTopLevelLiToParagraph() above, not because this
|
|
10378
|
-
// one guards anything measured.
|
|
10379
|
-
const changed = mutateListRun(() => {
|
|
10380
|
-
if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
|
|
10381
|
-
// v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
|
|
10382
|
-
// still had its OLD indent. That is fine for outdentListItem() — the
|
|
10383
|
-
// clamp's model rereads every element's CURRENT data-indent off the
|
|
10384
|
-
// DOM, so it self-corrects regardless of which elements the snapshot
|
|
10385
|
-
// names — but indentListItem() can now move a rule-(d) list-start
|
|
10386
|
-
// item (a bullet directly below an ordered item, say) whose own
|
|
10387
|
-
// pre-mutation run is `[li]` alone: nothing shallower ever preceded
|
|
10388
|
-
// it, because runBlocksOf() stops at a list-start by design. With no
|
|
10389
|
-
// anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
|
|
10390
|
-
// bound the indent this call just wrote against, and clamps it
|
|
10391
|
-
// straight back to 0 — silently undoing the write. A FRESH
|
|
10392
|
-
// listRunOf(li) call here — taken after the write above — walks back
|
|
10393
|
-
// from li's NEW indent and finds the real parent, exactly like the
|
|
10394
|
-
// post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
|
|
10395
|
-
// span) already does; using the same fresh call for the clamp keeps
|
|
10396
|
-
// the two consistent. Provably identical to the old stale `run` for
|
|
10397
|
-
// every case where li was never a list-start (listRunOf() slices by
|
|
10398
|
-
// INDEX RANGE, and neither the range's start/end index nor the
|
|
10399
|
-
// elements between them shift when only li's own indent changes).
|
|
10400
|
-
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
10401
|
-
return true;
|
|
10402
|
-
});
|
|
10403
|
-
if (!changed) return true;
|
|
10404
|
-
run = listRunOf(li);
|
|
10405
|
-
// Column-only: nothing's CONTENT moved, so every block in the span —
|
|
10406
|
-
// the target included — is a bystander whose source bytes must come
|
|
10407
|
-
// back untouched. Built once and shared with runLineOfBlock() below,
|
|
10408
|
-
// which indexes into the lines this very map decides.
|
|
10409
|
-
const carry = bystanderCarryOver(run, null);
|
|
10410
|
-
commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
10411
|
-
return true;
|
|
10412
|
-
}
|
|
12281
|
+
if (e.key === 'Tab') return liIndentStructure(li, run, e.shiftKey);
|
|
10413
12282
|
|
|
10414
12283
|
// Enter.
|
|
10415
12284
|
if (selectionSpansMultipleListItems()) {
|
|
@@ -11442,8 +13311,27 @@
|
|
|
11442
13311
|
while (n && !n.classList.contains('ed-block')) n = step(n);
|
|
11443
13312
|
return n;
|
|
11444
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
|
+
}
|
|
11445
13332
|
let dest = nextBlock(fromBlockEl);
|
|
11446
13333
|
if (!dest) return;
|
|
13334
|
+
const destWasTransient = isTransient(dest);
|
|
11447
13335
|
const srcStartLine = (blockRecOf(fromBlockEl) || {}).startLine;
|
|
11448
13336
|
const destIdentity = captureBlockIdentity(dest);
|
|
11449
13337
|
const ok = await switchAwayFrom();
|
|
@@ -11453,7 +13341,9 @@
|
|
|
11453
13341
|
const liveSrc = srcRec
|
|
11454
13342
|
? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
|
|
11455
13343
|
dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
|
|
11456
|
-
|
|
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; }
|
|
11457
13347
|
}
|
|
11458
13348
|
if (x !== null) arrowGoalX = x;
|
|
11459
13349
|
arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
|
|
@@ -16564,6 +18454,9 @@
|
|
|
16564
18454
|
// expression setDirty() paints the title dot from — not a second copy
|
|
16565
18455
|
// of it.
|
|
16566
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,
|
|
16567
18460
|
};
|
|
16568
18461
|
}
|
|
16569
18462
|
|
|
@@ -16944,6 +18837,12 @@
|
|
|
16944
18837
|
}
|
|
16945
18838
|
|
|
16946
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;
|
|
16947
18846
|
const blockEl = liveToolbarBlock();
|
|
16948
18847
|
switch (id) {
|
|
16949
18848
|
// v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
|
|
@@ -16980,8 +18879,8 @@
|
|
|
16980
18879
|
case 'indent': return indentCaretLi(1);
|
|
16981
18880
|
case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
|
|
16982
18881
|
case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
|
|
16983
|
-
case 'insert-after': return blockEl ?
|
|
16984
|
-
case 'insert-before': return blockEl ?
|
|
18882
|
+
case 'insert-after': return blockEl ? insertTransientBelow(blockEl) : undefined;
|
|
18883
|
+
case 'insert-before': return blockEl ? insertTransientAbove(blockEl) : undefined;
|
|
16985
18884
|
// backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
|
|
16986
18885
|
// See its own comment (shared with undoViaToolbar()/redoViaToolbar())
|
|
16987
18886
|
// for the caret rescue.
|
|
@@ -17095,6 +18994,7 @@
|
|
|
17095
18994
|
// gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
|
|
17096
18995
|
// heading gets a delta computed against its own current depth.
|
|
17097
18996
|
async function applyHeadingLevel(n) {
|
|
18997
|
+
if (await toolbarOnTransient('h' + n, liveToolbarBlock())) return;
|
|
17098
18998
|
const blockEl = liveToolbarBlock();
|
|
17099
18999
|
if (!blockEl) return;
|
|
17100
19000
|
if (blockEl.getAttribute('data-block-type') !== 'heading') {
|
|
@@ -17109,79 +19009,12 @@
|
|
|
17109
19009
|
}
|
|
17110
19010
|
|
|
17111
19011
|
// ── insert above ───────────────────────────────────────────────────────
|
|
17112
|
-
//
|
|
17113
|
-
//
|
|
17114
|
-
//
|
|
17115
|
-
//
|
|
17116
|
-
//
|
|
17117
|
-
|
|
17118
|
-
const rec = blockRecOf(blockEl);
|
|
17119
|
-
if (!rec) return;
|
|
17120
|
-
const preceding = blocks
|
|
17121
|
-
.filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
|
|
17122
|
-
.pop();
|
|
17123
|
-
if (preceding) {
|
|
17124
|
-
const anchorEl = blockElAtLine(preceding.startLine);
|
|
17125
|
-
if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
|
|
17126
|
-
}
|
|
17127
|
-
// Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
|
|
17128
|
-
// instead would leave the document's FIRST block permanently impossible
|
|
17129
|
-
// to precede: the ⠿ and + menus both insert below, so this button is the
|
|
17130
|
-
// only affordance that reaches that position at all.
|
|
17131
|
-
await insertParagraphAtTop(blockEl);
|
|
17132
|
-
}
|
|
17133
|
-
|
|
17134
|
-
// The document's first block has no predecessor to insert BELOW, and
|
|
17135
|
-
// commitBlockInsertion() only ever inserts after a block's range. Rather
|
|
17136
|
-
// than teach the pure core a direction (a change under byte-stability and
|
|
17137
|
-
// roundtrip fixtures), the insert is expressed as a REPLACE of that
|
|
17138
|
-
// block's own first source line by [skeleton, '', that same line] — the
|
|
17139
|
-
// same commitRangeEdit()/replaceLines() pipeline as every other edit,
|
|
17140
|
-
// therefore one undo op, and no new primitive.
|
|
17141
|
-
//
|
|
17142
|
-
// The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
|
|
17143
|
-
// for its reasons: the resolution can commit a dirty burst, whose
|
|
17144
|
-
// rerenderAll() detaches the very node this was called with.
|
|
17145
|
-
async function insertParagraphAtTop(blockEl) {
|
|
17146
|
-
const identity = captureBlockIdentity(blockEl);
|
|
17147
|
-
const selfSession = ownsOpenSession(blockEl);
|
|
17148
|
-
const ok = await switchAwayFrom();
|
|
17149
|
-
if (!ok) return;
|
|
17150
|
-
let liveBlockEl = blockEl;
|
|
17151
|
-
if (!document.body.contains(blockEl)) {
|
|
17152
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
17153
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
17154
|
-
if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
17155
|
-
}
|
|
17156
|
-
const rec = blockRecOf(liveBlockEl);
|
|
17157
|
-
// A block that owns no source line has an inverted range, and there is
|
|
17158
|
-
// no line to splice in front of.
|
|
17159
|
-
if (!rec || rec.endLine < rec.startLine) {
|
|
17160
|
-
refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
|
|
17161
|
-
return;
|
|
17162
|
-
}
|
|
17163
|
-
const at = rec.startLine;
|
|
17164
|
-
const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
|
|
17165
|
-
const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
|
|
17166
|
-
if (result.op === null) return;
|
|
17167
|
-
const prevLines = lines;
|
|
17168
|
-
lines = result.lines;
|
|
17169
|
-
const editRange = result.op
|
|
17170
|
-
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
17171
|
-
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
17172
|
-
delta: result.op.after.length - result.op.before.length }
|
|
17173
|
-
: undefined;
|
|
17174
|
-
const okRender = await safeRerenderAll(editRange);
|
|
17175
|
-
if (!okRender) {
|
|
17176
|
-
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
17177
|
-
return;
|
|
17178
|
-
}
|
|
17179
|
-
// The new paragraph starts exactly where the old first line did — same
|
|
17180
|
-
// pristine + focus contract insertBlockBelow() gives a skeleton insert.
|
|
17181
|
-
const target = blocks.find((b) => b.startLine === at);
|
|
17182
|
-
if (target) pristineInsert = { blockId: target.id };
|
|
17183
|
-
await focusInsertedBlock(at, 'paragraph');
|
|
17184
|
-
}
|
|
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.
|
|
17185
19018
|
|
|
17186
19019
|
// ── 大綱 ───────────────────────────────────────────────────────────────
|
|
17187
19020
|
// TWO attributes, chosen by viewport, and that is not an accident.
|
|
@@ -17390,6 +19223,12 @@
|
|
|
17390
19223
|
if (i > 0) newLines.push('');
|
|
17391
19224
|
newLines.push(' + ')');
|
|
17392
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
|
+
}
|
|
17393
19232
|
await insertBlockBelow(target, 'paragraph', { lines: newLines });
|
|
17394
19233
|
}
|
|
17395
19234
|
|
|
@@ -17623,7 +19462,14 @@
|
|
|
17623
19462
|
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
17624
19463
|
// foreign nodes the serializer would have to make sense of afterwards.
|
|
17625
19464
|
const pastedLines = md.split('\n');
|
|
17626
|
-
|
|
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',
|
|
17627
19473
|
{ lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
|
|
17628
19474
|
return;
|
|
17629
19475
|
}
|
|
@@ -18071,6 +19917,14 @@
|
|
|
18071
19917
|
dropHeldTyping();
|
|
18072
19918
|
return;
|
|
18073
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
|
+
}
|
|
18074
19928
|
if (flightSurface && flightHoldsKey(e)) {
|
|
18075
19929
|
e.preventDefault();
|
|
18076
19930
|
holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
|
|
@@ -18119,6 +19973,8 @@
|
|
|
18119
19973
|
// it either.
|
|
18120
19974
|
const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
18121
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);
|
|
18122
19978
|
handleBurstKeydown(e, wysArmedEl);
|
|
18123
19979
|
return;
|
|
18124
19980
|
}
|
|
@@ -18318,6 +20174,15 @@
|
|
|
18318
20174
|
// input/paste handling (class 'ed-wys-cell') is a separate branch at the
|
|
18319
20175
|
// top of each listener below — see handleTableCellFocusIn() above.
|
|
18320
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
|
+
}
|
|
18321
20186
|
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
18322
20187
|
if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
|
|
18323
20188
|
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
@@ -18329,7 +20194,22 @@
|
|
|
18329
20194
|
// be resolving from the focusout that just preceded this focusin — see
|
|
18330
20195
|
// switchAwayFrom()'s single-flight `switching`. Await it before
|
|
18331
20196
|
// starting a new burst so the two never race a concurrent commit.
|
|
18332
|
-
|
|
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
|
+
}
|
|
18333
20213
|
// v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
|
|
18334
20214
|
// arrow walk lands on the NEXT block from inside that same resolution, and
|
|
18335
20215
|
// its own focusin is queued behind this one. Starting a burst here would
|
|
@@ -18348,7 +20228,12 @@
|
|
|
18348
20228
|
// target. Re-resolve the equivalent LIVE node by block id and move
|
|
18349
20229
|
// focus there for real — that re-enters this same handler
|
|
18350
20230
|
// synchronously (switching is null by now), which starts the burst.
|
|
18351
|
-
|
|
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);
|
|
18352
20237
|
liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
18353
20238
|
if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
|
|
18354
20239
|
liveEditEl.focus();
|
|
@@ -18578,6 +20463,37 @@
|
|
|
18578
20463
|
setTimeout(() => { composedHeld = null; }, 0);
|
|
18579
20464
|
}, true);
|
|
18580
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
|
+
|
|
18581
20497
|
// Capture phase, matching the phase the ordering above was measured in.
|
|
18582
20498
|
document.addEventListener('beforeinput', (e) => {
|
|
18583
20499
|
if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
|
|
@@ -20432,4 +22348,9 @@
|
|
|
20432
22348
|
// selToolbar, …) is initialised and the bar's first deriveState() sees the
|
|
20433
22349
|
// armed document rather than an empty one.
|
|
20434
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();
|
|
20435
22356
|
})();
|