@helping-ai-workflow/md2doc 2.8.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +226 -0
- package/bin/md2doc.js +46 -33
- package/lib/editor/blockmap.js +75 -0
- package/lib/editor/cli.js +23 -0
- package/lib/editor/client.js +4739 -0
- package/lib/editor/history.js +94 -0
- package/lib/editor/inline-md.js +220 -0
- package/lib/editor/lineops.js +71 -0
- package/lib/editor/list-md.js +258 -0
- package/lib/editor/open.js +59 -0
- package/lib/editor/server.js +190 -0
- package/lib/editor/table-md.js +169 -0
- package/lib/md2doc.js +1110 -591
- package/package.json +2 -2
|
@@ -0,0 +1,4739 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* md2doc editor client runtime (Phase 1: raw-edit). Inlined into the edit
|
|
3
|
+
page after lineops.js; also requireable in node for the pure core. */
|
|
4
|
+
(function () {
|
|
5
|
+
const ops = (typeof window !== 'undefined' && window.md2docLineOps)
|
|
6
|
+
? window.md2docLineOps
|
|
7
|
+
: require('./lineops.js');
|
|
8
|
+
|
|
9
|
+
function extractBlockSource(lines, block) {
|
|
10
|
+
return lines.slice(block.startLine - 1, block.endLine).join('\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Pure: apply a raw-edit commit to an EXPLICIT line range (startLine..endLine,
|
|
14
|
+
// 1-indexed inclusive); push onto stack.
|
|
15
|
+
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
16
|
+
// The shift anchor is the LAST block whose endLine <= the range's endLine,
|
|
17
|
+
// so that only blocks after the committed range are shifted — not blocks
|
|
18
|
+
// inside it. Guards the no-anchor case (range before every block).
|
|
19
|
+
function commitRangeEdit(state, startLine, endLine, newText) {
|
|
20
|
+
const before = state.lines.slice(startLine - 1, endLine);
|
|
21
|
+
const after = newText.split('\n');
|
|
22
|
+
if (before.join('\n') === after.join('\n')) {
|
|
23
|
+
return { lines: state.lines, blocks: state.blocks, op: null };
|
|
24
|
+
}
|
|
25
|
+
const op = { startLine, endLine, before, after };
|
|
26
|
+
const r = ops.replaceLines(state.lines, startLine, endLine, after);
|
|
27
|
+
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
28
|
+
const blocks = anchor ? ops.shiftBlocks(state.blocks, anchor.id, r.delta) : state.blocks;
|
|
29
|
+
state.stack.push(op);
|
|
30
|
+
return { lines: r.lines, blocks, op };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pure: apply a raw-edit commit to (lines, blocks); push onto stack.
|
|
34
|
+
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
35
|
+
// Wrapper over commitRangeEdit: looks up the block by id and delegates.
|
|
36
|
+
function commitEdit(state, blockId, newText) {
|
|
37
|
+
const block = state.blocks.find((b) => b.id === blockId);
|
|
38
|
+
return commitRangeEdit(state, block.startLine, block.endLine, newText);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generalized range removal: deletes startLine..endLine (zero lines) and
|
|
42
|
+
// absorbs exactly ONE adjacent blank line — the same blank-line contract as
|
|
43
|
+
// commitListBlockRemoval() but for an EXPLICIT range instead of a single
|
|
44
|
+
// block id. Used by the li burst's empty-run path (Task 7) and by the
|
|
45
|
+
// list-burst empty-list path via the wrapper below.
|
|
46
|
+
// RULING F-C: the shiftBlocks anchor is the last block whose endLine is
|
|
47
|
+
// within the requested range, computed BEFORE blank-line absorption so that
|
|
48
|
+
// widening endLine to cover an adjacent blank never reaches across into the
|
|
49
|
+
// next real block and mis-shifts it.
|
|
50
|
+
function commitRangeRemoval(state, startLine, endLine) {
|
|
51
|
+
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
52
|
+
let sl = startLine, el = endLine;
|
|
53
|
+
// state.lines[el] (0-indexed) is the line immediately AFTER the range.
|
|
54
|
+
if (state.lines[el] !== undefined && state.lines[el].trim() === '') {
|
|
55
|
+
el += 1;
|
|
56
|
+
} else if (state.lines[sl - 2] !== undefined && state.lines[sl - 2].trim() === '') {
|
|
57
|
+
sl -= 1;
|
|
58
|
+
}
|
|
59
|
+
const before = state.lines.slice(sl - 1, el);
|
|
60
|
+
const op = { startLine: sl, endLine: el, before, after: [] };
|
|
61
|
+
const r = ops.replaceLines(state.lines, sl, el, []);
|
|
62
|
+
const blocks = anchor ? ops.shiftBlocks(state.blocks, anchor.id, r.delta) : state.blocks;
|
|
63
|
+
state.stack.push(op);
|
|
64
|
+
return { lines: r.lines, blocks, op };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Task 4 fix (review, Important): removing the LAST remaining item of a
|
|
68
|
+
// list block (empty-Enter on a list with exactly one item) serializes to
|
|
69
|
+
// '' — committing that through commitEdit() would replace the block's
|
|
70
|
+
// line range with [''] (ONE blank line: `newText.split('\n')` on an empty
|
|
71
|
+
// string is `['']`, not `[]`), leaving stray diff noise instead of
|
|
72
|
+
// cleanly closing the gap. Thin wrapper over commitRangeRemoval() above —
|
|
73
|
+
// see that function for the exact byte-level contract. Verified against the
|
|
74
|
+
// reviewer's exact probe: `# Doc\n\n- Only\n\nTrailer` -> `# Doc\n\nTrailer`
|
|
75
|
+
// (exactly one separating blank line) — see test/editor-client.test.js.
|
|
76
|
+
function commitListBlockRemoval(state, blockId) {
|
|
77
|
+
const block = state.blocks.find((b) => b.id === blockId);
|
|
78
|
+
return commitRangeRemoval(state, block.startLine, block.endLine);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Phase 3 §10-gap fix: inserts a NEW block's `newBlockLines` directly below
|
|
82
|
+
// the block identified by `blockId`, via lineops.insertLines() — mirrors
|
|
83
|
+
// commitListBlockRemoval()'s blank-line math ABOVE, but in reverse: that
|
|
84
|
+
// function absorbs an EXISTING neighboring blank line to avoid leaving a
|
|
85
|
+
// double blank behind after a removal; this one REUSES an existing
|
|
86
|
+
// trailing blank (when the hovered block already has one — the normal
|
|
87
|
+
// mid-document case) as the new block's OWN trailing separator, instead of
|
|
88
|
+
// inserting a second one next to it. A leading blank is always inserted
|
|
89
|
+
// fresh (the hovered block's own trailing content never carries one). Two
|
|
90
|
+
// cases for what follows the hovered block:
|
|
91
|
+
// - a blank line (or nothing — true EOF): reuse it / nothing needed, so
|
|
92
|
+
// `after` is just [blank, ...newBlockLines] — the pre-existing blank
|
|
93
|
+
// (or plain end-of-file) becomes/stays the separator to whatever's
|
|
94
|
+
// next.
|
|
95
|
+
// - non-blank content immediately follows (no blank neighbor — an edge
|
|
96
|
+
// case malformed input could produce): a fresh trailing blank is
|
|
97
|
+
// added too, or the new block would merge into the next one when
|
|
98
|
+
// re-lexed.
|
|
99
|
+
// The resulting op is a zero-width "before" range (nothing existed at the
|
|
100
|
+
// insertion point to replace) — same trick commitListBlockRemoval() uses
|
|
101
|
+
// in the opposite direction (a zero-width "after" range) to let the
|
|
102
|
+
// existing UndoStack undo()/redo() pair (lib/editor/lineops.js) handle a
|
|
103
|
+
// pure insertion/pure removal without a third op shape.
|
|
104
|
+
function commitBlockInsertion(state, blockId, newBlockLines) {
|
|
105
|
+
const block = state.blocks.find((b) => b.id === blockId);
|
|
106
|
+
const endLine = block.endLine;
|
|
107
|
+
const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
|
|
108
|
+
const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '';
|
|
109
|
+
const after = needsTrailingBlank
|
|
110
|
+
? ['', ...newBlockLines, '']
|
|
111
|
+
: ['', ...newBlockLines];
|
|
112
|
+
const op = { startLine: endLine + 1, endLine, before: [], after };
|
|
113
|
+
const r = ops.insertLines(state.lines, endLine, after);
|
|
114
|
+
const blocks = ops.shiftBlocks(state.blocks, blockId, r.delta);
|
|
115
|
+
state.stack.push(op);
|
|
116
|
+
// The new block's own content starts one line after the leading blank
|
|
117
|
+
// this function always inserts (see `after` above — its first element
|
|
118
|
+
// is always the fresh leading blank) — callers use this to locate the
|
|
119
|
+
// freshly-inserted block in the blocks array a subsequent full
|
|
120
|
+
// rerenderAll() (which recomputes blocks server-side) hands back.
|
|
121
|
+
const newStartLine = op.startLine + 1;
|
|
122
|
+
return { lines: r.lines, blocks, op, newStartLine };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function headingDepthOf(line) {
|
|
126
|
+
const m = line.match(/^(#{1,6})\s?/);
|
|
127
|
+
return m ? m[1].length : 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Final-review Finding 5: an EMPTY heading (rest === '') used to emit
|
|
131
|
+
// '#'.repeat(newDepth) + ' ' — the trailing space survives even with
|
|
132
|
+
// nothing after it, a spec §4 no-trailing-whitespace violation. marked
|
|
133
|
+
// still lexes a bare '#'.repeat(depth) run (no space, nothing after) as a
|
|
134
|
+
// valid empty-text heading token (verified: marked.lexer('##') ->
|
|
135
|
+
// {type:'heading', depth:2, text:''}), so the space is only needed when
|
|
136
|
+
// there IS content after it.
|
|
137
|
+
function withHeadingDepth(line, newDepth) {
|
|
138
|
+
const m = line.match(/^#{1,6}\s?/);
|
|
139
|
+
const rest = m ? line.slice(m[0].length) : line;
|
|
140
|
+
return rest === '' ? '#'.repeat(newDepth) : '#'.repeat(newDepth) + ' ' + rest;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof module === 'object' && module.exports) {
|
|
144
|
+
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, headingDepthOf, withHeadingDepth };
|
|
145
|
+
return; // node: pure core only
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── DOM wiring (browser only) ─────────────────────────────────────────
|
|
149
|
+
const ED = window.__ED__;
|
|
150
|
+
const inlineMd = window.md2docInlineMd;
|
|
151
|
+
const tableMd = window.md2docTableMd;
|
|
152
|
+
const listMd = window.md2docListMd;
|
|
153
|
+
const historyLib = window.md2docHistory;
|
|
154
|
+
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
155
|
+
const stack = new ops.UndoStack();
|
|
156
|
+
const baseTitle = document.title;
|
|
157
|
+
const contentEl = document.querySelector('.content');
|
|
158
|
+
|
|
159
|
+
// Click-to-switch substrate (Phase-2 Task 1; replaces the old "refuse a
|
|
160
|
+
// second block's editor outright" policy). At most one block editor is
|
|
161
|
+
// open at a time, but instead of refusing a switch away from it, the open
|
|
162
|
+
// editor is resolved automatically: unmodified → silently cancelled (same
|
|
163
|
+
// effect as Esc); modified → auto-committed. Both raw-edit and (future)
|
|
164
|
+
// WYSIWYG editors expose the same shape here so switchAwayFrom() below
|
|
165
|
+
// works uniformly regardless of which kind of editor is open:
|
|
166
|
+
// { blockEl, hasChanges(), commitNow(): Promise<boolean>, cancelNow(): void }
|
|
167
|
+
//
|
|
168
|
+
// undo()/redo() are a DIFFERENT collision, not just "open a second
|
|
169
|
+
// editor": they replace the ENTIRE .content subtree (via
|
|
170
|
+
// safeRerenderAll()) regardless of which block, if any, currently has an
|
|
171
|
+
// editor open. If that swap ran while some block's editor was open
|
|
172
|
+
// without resolving it first, its textarea would be detached without
|
|
173
|
+
// ever running its own cancelNow()/commitNow() — the only place that
|
|
174
|
+
// clears `activeEditor` — so `activeEditor` would be left pointing at a
|
|
175
|
+
// node no longer in the document, and every future attempt to open a
|
|
176
|
+
// block's editor would then find a stale `activeEditor` (a real
|
|
177
|
+
// regression found in review: silent total lockout, recoverable only by
|
|
178
|
+
// reloading). So undo()/redo() also resolve any open editor FIRST via
|
|
179
|
+
// switchAwayFrom() below — same resolution as a block switch, just
|
|
180
|
+
// triggered by the undo/redo collision instead of the click collision.
|
|
181
|
+
//
|
|
182
|
+
// Belt-and-braces: rerenderAll() also unconditionally nulls `activeEditor`
|
|
183
|
+
// right after every successful .content swap, regardless of caller, so a
|
|
184
|
+
// future safeRerenderAll() call site that forgets this pre-check can
|
|
185
|
+
// never reproduce the lockout — any editor that was open is gone by
|
|
186
|
+
// construction the moment the swap happens.
|
|
187
|
+
let activeEditor = null; // { blockEl, hasChanges(), commitNow(), cancelNow() } | null
|
|
188
|
+
|
|
189
|
+
// Task 2 (Phase 3): the currently-open always-on WYSIWYG "burst" — at most
|
|
190
|
+
// one paragraph/heading contenteditable surface is being edited at a time,
|
|
191
|
+
// tracked separately from `activeEditor` above (which covers the OLDER
|
|
192
|
+
// raw-edit / table-cell editor shape). See the "always-on WYSIWYG burst"
|
|
193
|
+
// section further down for the full shape and lifecycle.
|
|
194
|
+
let currentBurst = null; // { blockEl, editEl, blockId, blockType, depth, original, history } | null
|
|
195
|
+
|
|
196
|
+
// §10-gap fix (review): the block insertBlockBelow() most recently
|
|
197
|
+
// inserted, tracked from the moment its edit surface is first focused
|
|
198
|
+
// until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
|
|
199
|
+
// whichever comes first, one exit only. "Insert +, click away without
|
|
200
|
+
// typing" is an ordinary changed-my-mind action (verified against ALL 5
|
|
201
|
+
// skeletons: an untouched insert would otherwise leave behind an
|
|
202
|
+
// invisible ZWSP paragraph, a heading with a spec-§4-violating trailing
|
|
203
|
+
// space, a list item that fails the documented marker pattern, or a
|
|
204
|
+
// visually-empty table/code block) — every resolution path below checks
|
|
205
|
+
// this and, if the block's content is STILL byte-identical to what was
|
|
206
|
+
// inserted (never edited), auto-removes it via discardPristineInsert()
|
|
207
|
+
// instead of leaving the skeleton on disk. Editing ANYTHING clears it
|
|
208
|
+
// (see each call site below) — from that point on the block is a normal,
|
|
209
|
+
// permanently-committed one like any other.
|
|
210
|
+
let pristineInsert = null; // { blockId } | null
|
|
211
|
+
|
|
212
|
+
// Task 5 fix (found via a standalone repro harness — see the task-5
|
|
213
|
+
// report): a table burst's tableEl.innerHTML REASSIGNMENT (revert /
|
|
214
|
+
// undo / redo — the only mutations that replace the WHOLE table, unlike
|
|
215
|
+
// insertRow()/insertColumn() which patch it in place) removes whichever
|
|
216
|
+
// cell currently has focus. Chromium runs the focus-fixup "unfocus"
|
|
217
|
+
// step (firing a synchronous blur/focusout) BEFORE the node is actually
|
|
218
|
+
// detached — NOT after, as a naive reading of "removed nodes lose focus"
|
|
219
|
+
// would suggest — so at the moment that focusout's handler runs,
|
|
220
|
+
// `e.target.closest('table')` STILL resolves to the live `tableEl`
|
|
221
|
+
// (its `parentNode` hasn't been cleared yet), and the handler's
|
|
222
|
+
// "still inside the table" exclusion (which reads `e.relatedTarget`,
|
|
223
|
+
// itself still null/unset at that same instant, since nothing has
|
|
224
|
+
// received focus yet) does NOT catch it either. Without this flag, that
|
|
225
|
+
// spurious focusout was read as "focus genuinely left the table" and
|
|
226
|
+
// called switchAwayFrom() — silently RE-COMMITTING the very state the
|
|
227
|
+
// revert/undo/redo was in the middle of discarding, then wiping focus to
|
|
228
|
+
// <body> once the resulting rerenderAll() swapped .content. Set true for
|
|
229
|
+
// the exact synchronous span of each such innerHTML reassignment (see
|
|
230
|
+
// tableBurstUndo()/tableBurstRedo()/revertTableBurstAndEnd() below); the
|
|
231
|
+
// focusout listener checks it FIRST and no-ops the whole branch while set.
|
|
232
|
+
let suppressTableFocusout = false;
|
|
233
|
+
|
|
234
|
+
// Task 8: the SAME Chromium behaviour, one substrate over — see
|
|
235
|
+
// `suppressTableFocusout` just above for the full description of the quirk.
|
|
236
|
+
// A structural list key (Enter / Tab / Shift+Tab on a per-li block) moves,
|
|
237
|
+
// splits or removes the very <li> whose `.ed-li-text` currently has focus,
|
|
238
|
+
// so Chromium runs its unfocus step — firing a synchronous focusout — with
|
|
239
|
+
// the run still in its PRE-mutation shape and `currentBurst` still live.
|
|
240
|
+
// Unguarded, that focusout reaches resolveBurst(), whose li branch happily
|
|
241
|
+
// serializes and COMMITS the run as it stood before the key: one keystroke
|
|
242
|
+
// becomes two undo ops, and an empty-Enter re-commits the very item it is
|
|
243
|
+
// removing (observed: '- <br>' written back for an item that had just been
|
|
244
|
+
// deleted). Set true for the exact synchronous span of each structural
|
|
245
|
+
// mutation — see mutateListRun() below; the focusout listener checks it
|
|
246
|
+
// FIRST, alongside the table flag, and no-ops the whole branch while set.
|
|
247
|
+
// The burst is NOT lost by suppressing it: commitListStructure() ends it
|
|
248
|
+
// explicitly right afterwards, and re-serializes the LIVE run, so any
|
|
249
|
+
// typed-but-uncommitted text in the run is still committed.
|
|
250
|
+
let suppressLiFocusout = false;
|
|
251
|
+
|
|
252
|
+
// Runs one structural list-DOM mutation with that focusout suppressed.
|
|
253
|
+
// try/finally so a throw inside `fn` can never leave the flag stuck on
|
|
254
|
+
// (which would silently disable every subsequent blur-commit in the page).
|
|
255
|
+
function mutateListRun(fn) {
|
|
256
|
+
suppressLiFocusout = true;
|
|
257
|
+
try {
|
|
258
|
+
return fn();
|
|
259
|
+
} finally {
|
|
260
|
+
suppressLiFocusout = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Resolve whatever editor is currently open BEFORE a caller proceeds to
|
|
265
|
+
// something that must not run concurrently with an open editor (opening a
|
|
266
|
+
// DIFFERENT block's editor, dismissing the bar on an outside click, or a
|
|
267
|
+
// .content-replacing undo/redo). Returns true when it's safe to proceed:
|
|
268
|
+
// no editor was open, or it was cleanly resolved (cancelled if unmodified,
|
|
269
|
+
// committed if modified). Returns false only when a modified editor's
|
|
270
|
+
// auto-commit FAILED (server error / network) — the caller must abandon
|
|
271
|
+
// whatever it was about to do; the open editor stays open, with its
|
|
272
|
+
// banner already shown by the failed commitNow(), as the visible reason
|
|
273
|
+
// why (state consistency over convenience).
|
|
274
|
+
//
|
|
275
|
+
// Single-flight: outside-click and undo()/redo()'s pre-check are two
|
|
276
|
+
// INDEPENDENT triggers that can both fire from near-simultaneous user
|
|
277
|
+
// input (e.g. a mouse blur immediately followed by Ctrl+Z) before either
|
|
278
|
+
// has resolved. Without a guard, a second caller arriving while the first
|
|
279
|
+
// is still awaiting activeEditor.commitNow() would see the SAME
|
|
280
|
+
// activeEditor (still non-null, still hasChanges() === true — nothing
|
|
281
|
+
// about the in-flight commit has touched the textarea's value yet) and
|
|
282
|
+
// fire a SECOND, fully independent commit() on the very same closure:
|
|
283
|
+
// two concurrent /api/render calls racing, `lines` reflecting whichever
|
|
284
|
+
// one happened to run its synchronous portion last while the DOM ends up
|
|
285
|
+
// reflecting whichever response resolves last — silent save/DOM
|
|
286
|
+
// divergence. `switching` caches the in-flight promise so every
|
|
287
|
+
// concurrent caller shares the ONE resolution instead. This also covers
|
|
288
|
+
// openRawEditor()'s defense-in-depth switchAwayFrom() call (see its
|
|
289
|
+
// comment) for the same reason — it's just another caller.
|
|
290
|
+
let switching = null;
|
|
291
|
+
function switchAwayFrom() {
|
|
292
|
+
if (switching) return switching;
|
|
293
|
+
switching = resolveOpenSession().finally(() => { switching = null; });
|
|
294
|
+
return switching;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Task 2 (Phase 3): resolves BOTH kinds of "something is open" state this
|
|
298
|
+
// file can have at once — at most one of the two is ever non-null in
|
|
299
|
+
// practice (a block is either an always-on WYSIWYG burst, OR an
|
|
300
|
+
// old-style raw-edit/table-cell `activeEditor`, never both), but this
|
|
301
|
+
// checks both defensively so switchAwayFrom() stays a single, complete
|
|
302
|
+
// "make it safe to proceed" gate for every caller (undo/redo/save, the
|
|
303
|
+
// table click delegator, the burst's own focusout handler, …). See
|
|
304
|
+
// resolveBurst() below for the burst half of this — same true/false
|
|
305
|
+
// contract as activeEditor.commitNow() (false → the failed session stays
|
|
306
|
+
// open with its edit intact, banner already shown, caller must abandon
|
|
307
|
+
// whatever it was about to do).
|
|
308
|
+
async function resolveOpenSession() {
|
|
309
|
+
if (currentBurst) {
|
|
310
|
+
const ok = await resolveBurst();
|
|
311
|
+
if (!ok) return false;
|
|
312
|
+
}
|
|
313
|
+
if (!activeEditor) return true;
|
|
314
|
+
if (!activeEditor.hasChanges()) {
|
|
315
|
+
// §10-gap fix (review): cancelNow() (the raw editor's
|
|
316
|
+
// cancelAndMaybeDiscard()) now itself returns true/false — false
|
|
317
|
+
// only when it was a pristine block whose own auto-removal render
|
|
318
|
+
// failed. Must be awaited/propagated the same way commitNow() below
|
|
319
|
+
// already is, or a caller relying on switchAwayFrom()'s true/false
|
|
320
|
+
// contract (proceed only when safe) could act on stale state.
|
|
321
|
+
return await activeEditor.cancelNow();
|
|
322
|
+
}
|
|
323
|
+
return await activeEditor.commitNow(); // false → editor stays open, banner already shown
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function setDirty() {
|
|
327
|
+
document.title = (stack.dirtyDepth !== 0 ? '● ' : '') + baseTitle;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── banners (conflict / render-failed / save-failed) ──────────────────
|
|
331
|
+
// One shared, dismissible banner element. `actionLabel`+`onAction` add an
|
|
332
|
+
// extra button ahead of the always-present ✕ dismiss button (e.g. the
|
|
333
|
+
// conflict banner's "Reload"); omit them for a plain dismiss-only notice.
|
|
334
|
+
let activeBanner = null;
|
|
335
|
+
function showBanner(message, actionLabel, onAction) {
|
|
336
|
+
if (activeBanner) { activeBanner.remove(); activeBanner = null; }
|
|
337
|
+
const el = document.createElement('div');
|
|
338
|
+
el.className = 'ed-conflict';
|
|
339
|
+
const msg = document.createElement('span');
|
|
340
|
+
msg.textContent = message;
|
|
341
|
+
el.appendChild(msg);
|
|
342
|
+
if (actionLabel && onAction) {
|
|
343
|
+
const actionBtn = document.createElement('button');
|
|
344
|
+
actionBtn.type = 'button';
|
|
345
|
+
actionBtn.textContent = actionLabel;
|
|
346
|
+
actionBtn.addEventListener('click', onAction);
|
|
347
|
+
el.appendChild(actionBtn);
|
|
348
|
+
}
|
|
349
|
+
const dismissBtn = document.createElement('button');
|
|
350
|
+
dismissBtn.type = 'button';
|
|
351
|
+
dismissBtn.textContent = '✕';
|
|
352
|
+
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
353
|
+
dismissBtn.addEventListener('click', () => {
|
|
354
|
+
el.remove();
|
|
355
|
+
if (activeBanner === el) activeBanner = null;
|
|
356
|
+
});
|
|
357
|
+
el.appendChild(dismissBtn);
|
|
358
|
+
document.body.appendChild(el);
|
|
359
|
+
activeBanner = el;
|
|
360
|
+
return el;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function showConflictBanner() {
|
|
364
|
+
showBanner(
|
|
365
|
+
'File changed on disk — reload to pick up external edits ' +
|
|
366
|
+
'(your unsaved changes will be lost).',
|
|
367
|
+
'Reload',
|
|
368
|
+
() => location.reload()
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function describeFailure(e) {
|
|
373
|
+
return (e && e.message) ? e.message : String(e);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// res is a fetch Response with a non-2xx/409 status (or undefined, for a
|
|
377
|
+
// network-level throw where no response ever arrived). Best-effort pulls
|
|
378
|
+
// a server-provided {error} message; falls back to the HTTP status.
|
|
379
|
+
async function describeHttpFailure(res) {
|
|
380
|
+
let reason = 'HTTP ' + res.status;
|
|
381
|
+
try {
|
|
382
|
+
const body = await res.json();
|
|
383
|
+
if (body && body.error) reason = body.error;
|
|
384
|
+
} catch (e) {
|
|
385
|
+
// no JSON body (or parse failure) — keep the HTTP-status reason
|
|
386
|
+
}
|
|
387
|
+
return reason;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── full re-render (used by commit / undo / redo) ──────────────────────
|
|
391
|
+
// Returns true on success (DOM + blocks + dirty-dot all updated). Returns
|
|
392
|
+
// false on ANY failure — network throw, non-ok status, or a malformed
|
|
393
|
+
// response — WITHOUT touching contentEl.innerHTML or `blocks`, and shows
|
|
394
|
+
// a dismissible banner explaining what happened. Never throws: every
|
|
395
|
+
// await is inside its own try/catch, so callers never see a rejection.
|
|
396
|
+
async function rerenderAll() {
|
|
397
|
+
const scrollY = window.scrollY;
|
|
398
|
+
let res;
|
|
399
|
+
try {
|
|
400
|
+
res = await fetch('/api/render', {
|
|
401
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
402
|
+
body: JSON.stringify({ fileId: ED.fileId, content: lines.join('\n') }),
|
|
403
|
+
});
|
|
404
|
+
} catch (e) {
|
|
405
|
+
showBanner('Render failed — network error (' + describeFailure(e) +
|
|
406
|
+
'). Your edit was not applied.', null, null);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
const reason = await describeHttpFailure(res);
|
|
411
|
+
showBanner('Render failed — ' + reason + '. Your edit was not applied.', null, null);
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
let j;
|
|
415
|
+
try {
|
|
416
|
+
j = await res.json();
|
|
417
|
+
} catch (e) {
|
|
418
|
+
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
if (typeof j.bodyHtml !== 'string' || !Array.isArray(j.blocks)) {
|
|
422
|
+
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
blocks = j.blocks;
|
|
426
|
+
contentEl.innerHTML = j.bodyHtml;
|
|
427
|
+
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
428
|
+
// attach a ⠿ handle to every non-table block) in the freshly-swapped
|
|
429
|
+
// DOM — see armEditables() below. Must run before anything else touches
|
|
430
|
+
// the fresh nodes (diagram re-init, reader rebind, focus restoration).
|
|
431
|
+
armEditables(contentEl);
|
|
432
|
+
// Whatever editor (if any) was open a moment ago just got detached by
|
|
433
|
+
// the innerHTML replacement above — its own restore()/commit() never
|
|
434
|
+
// ran, so it never got a chance to null this out itself. Do it here,
|
|
435
|
+
// unconditionally, on every successful swap: this is what makes the
|
|
436
|
+
// undo/redo lockout regression (see the `activeEditor` comment above)
|
|
437
|
+
// structurally impossible even from a call site that forgets to call
|
|
438
|
+
// switchAwayFrom() first.
|
|
439
|
+
activeEditor = null;
|
|
440
|
+
// §10-gap fix (review): same belt-and-braces reasoning for
|
|
441
|
+
// `pristineInsert` — its window is meant to close via one of the
|
|
442
|
+
// explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
|
|
443
|
+
// revertTableBurstAndEnd()/cancelAndMaybeDiscard()) BEFORE any
|
|
444
|
+
// rerenderAll() reaches here, since every one of those (or the
|
|
445
|
+
// caller that triggered a DIFFERENT commit instead) already went
|
|
446
|
+
// through switchAwayFrom() first. Reset unconditionally here too,
|
|
447
|
+
// same "structurally impossible to leak" contract as activeEditor
|
|
448
|
+
// just above — this call site is set strictly AFTER its own
|
|
449
|
+
// insertBlockBelow() rerenderAll() awaits, so it can never clobber a
|
|
450
|
+
// fresh assignment.
|
|
451
|
+
pristineInsert = null;
|
|
452
|
+
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
453
|
+
// WYSIWYG burst — whatever block had one open a moment ago was just
|
|
454
|
+
// detached by the innerHTML replacement above, so its own focusout
|
|
455
|
+
// resolution never got a chance to null this out itself (this is
|
|
456
|
+
// reached on the SUCCESS path of a commit that originated from the
|
|
457
|
+
// burst itself, where resolveBurst() is still on the stack above this
|
|
458
|
+
// rerenderAll() call — nulling here, not there, is what keeps this a
|
|
459
|
+
// single source of truth, mirroring activeEditor just above).
|
|
460
|
+
if (currentBurst) { currentBurst.history.dispose(); currentBurst = null; }
|
|
461
|
+
// Task 5: same reasoning applies to the hover-edge insert bubbles —
|
|
462
|
+
// whatever table they were positioned against a moment ago was just
|
|
463
|
+
// destroyed by the innerHTML replacement above, so a stale bubble left
|
|
464
|
+
// visible (pointing at now-detached geometry) would misbehave on the
|
|
465
|
+
// next click. Reset unconditionally here too, same idiom as
|
|
466
|
+
// resetSelToolbarState() below. Same reasoning for the row/column grip
|
|
467
|
+
// handles — hideTableGrips() (defined alongside the Task 6 edge menu
|
|
468
|
+
// below) clears their tracked table/row/column references too.
|
|
469
|
+
hideTableInsertBubbles();
|
|
470
|
+
hideTableGrips();
|
|
471
|
+
// Task 4 fix (review finding): the SAME reasoning applies to the
|
|
472
|
+
// selection toolbar and its document-level selectionchange listener —
|
|
473
|
+
// whatever WYSIWYG session was open a moment ago just got detached by
|
|
474
|
+
// the innerHTML replacement above, so its cancel()/commit() never ran to
|
|
475
|
+
// tear this down itself. Every call site today resolves the session via
|
|
476
|
+
// switchAwayFrom() first (making this technically unreachable), but that
|
|
477
|
+
// safety depends on every FUTURE call site remembering to — exactly how
|
|
478
|
+
// the Task 3 listener-leak regression happened (see openWysiwygEditor()'s
|
|
479
|
+
// cancel() comment). Reset unconditionally here too, so a future call
|
|
480
|
+
// site can never reproduce that failure mode for the toolbar either.
|
|
481
|
+
// resetSelToolbarState() is idempotent (safe even when nothing was open).
|
|
482
|
+
resetSelToolbarState();
|
|
483
|
+
// Task 6: same belt-and-braces reasoning again for the edge-click menu
|
|
484
|
+
// and any in-flight row drag — whatever table they referenced a moment
|
|
485
|
+
// ago was just destroyed by the innerHTML replacement above.
|
|
486
|
+
hideTableEdgeMenu();
|
|
487
|
+
cancelTeDrag();
|
|
488
|
+
// Final-review Finding 5b (Important): same belt-and-braces reasoning
|
|
489
|
+
// again for the ⠿ gutter menu — `toggleGutterMenu()` appends the ONE
|
|
490
|
+
// shared `gutterMenu` node as a CHILD of whichever block it's open for
|
|
491
|
+
// (see its own comment), so the innerHTML replacement above just
|
|
492
|
+
// detached it (along with the block it was open on) without its own
|
|
493
|
+
// close path ever running. Left uncleared, `gutterMenuBlockEl` keeps
|
|
494
|
+
// pointing at a detached node: the NEXT toggleGutterMenu() call on that
|
|
495
|
+
// same (now-stale) reference would incorrectly treat the menu as
|
|
496
|
+
// already open (its `gutterMenuBlockEl === blockEl` toggle-closed
|
|
497
|
+
// check comparing against a node no future click can ever produce
|
|
498
|
+
// again) instead of opening fresh on whatever block is actually
|
|
499
|
+
// clicked. closeGutterMenu() is idempotent (safe even when nothing is
|
|
500
|
+
// open — same contract as resetSelToolbarState()/hideTableEdgeMenu()).
|
|
501
|
+
closeGutterMenu();
|
|
502
|
+
// §10-gap fix: same belt-and-braces reasoning for the + insert menu —
|
|
503
|
+
// it's the same "singleton node appended as a child of whichever block
|
|
504
|
+
// it's open for" idiom as gutterMenu just above.
|
|
505
|
+
closeInsertMenu();
|
|
506
|
+
window.scrollTo(0, scrollY);
|
|
507
|
+
if (window.__md2docInitDiagrams) {
|
|
508
|
+
try {
|
|
509
|
+
window.__md2docInitDiagrams(contentEl);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
// Phase-1 known limitation: if an edit introduces the FIRST block of
|
|
512
|
+
// a diagram type the initial page never loaded (e.g. the first
|
|
513
|
+
// ```mermaid fence in a doc that had none at load time), the
|
|
514
|
+
// library global is undefined for that type and the block stays as
|
|
515
|
+
// raw/unrendered markup until the page is reloaded. Never let that
|
|
516
|
+
// surface as an uncaught exception that would break the rest of the
|
|
517
|
+
// editor (block selection, save, undo).
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Finding 4: reader-runtime features (TOC highlight / breadcrumb via the
|
|
521
|
+
// IntersectionObserver, the zoom-resize scroll anchor's heading binary
|
|
522
|
+
// search) all read heading nodes captured once at initial page load
|
|
523
|
+
// (see lib/md2doc.js's reader-runtime <script>). The innerHTML swap
|
|
524
|
+
// above just detached every one of those nodes. Sibling to
|
|
525
|
+
// __md2docInitDiagrams above: re-query the live heading nodes and
|
|
526
|
+
// rebind the observer onto them so those features keep working after a
|
|
527
|
+
// commit instead of silently going dead.
|
|
528
|
+
if (window.__md2docRebindReader) {
|
|
529
|
+
try {
|
|
530
|
+
window.__md2docRebindReader();
|
|
531
|
+
} catch (e) {
|
|
532
|
+
// Never let a reader-runtime rebind failure break block selection/save/undo.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
setDirty();
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Defensive wrapper around every rerenderAll() call site: guarantees the
|
|
540
|
+
// caller never sees an unhandled rejection even if a future change to
|
|
541
|
+
// rerenderAll() (or one of its callees) introduces a stray throw.
|
|
542
|
+
async function safeRerenderAll() {
|
|
543
|
+
try {
|
|
544
|
+
return await rerenderAll();
|
|
545
|
+
} catch (e) {
|
|
546
|
+
showBanner('Render failed — unexpected error (' + describeFailure(e) +
|
|
547
|
+
'). Your edit was not applied.', null, null);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function autoSize(ta) {
|
|
553
|
+
ta.style.height = 'auto';
|
|
554
|
+
ta.style.height = (ta.scrollHeight + 2) + 'px';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function openRawEditor(blockEl) {
|
|
558
|
+
if (blockEl.querySelector('.ed-raw')) return; // already editing this block
|
|
559
|
+
if (activeEditor && activeEditor.blockEl !== blockEl) {
|
|
560
|
+
// Click-to-switch (see `activeEditor` / switchAwayFrom() comments
|
|
561
|
+
// above): resolve whatever editor IS open before opening this one.
|
|
562
|
+
// Defense in depth — the delegated click listener (wireBlockSelection
|
|
563
|
+
// below) already resolves this before a degraded block's click (or the
|
|
564
|
+
// ⠿ menu's "MD 原始碼" escape hatch) ever calls openRawEditor(), so by
|
|
565
|
+
// the time this function runs `activeEditor` is normally already null;
|
|
566
|
+
// this guard just makes openRawEditor() safe to call directly too. If
|
|
567
|
+
// a switchAwayFrom() triggered elsewhere (outside-click, undo/redo)
|
|
568
|
+
// is still in flight when this call lands, switchAwayFrom()'s own
|
|
569
|
+
// single-flight cache (`switching`) is what makes THIS call safe to
|
|
570
|
+
// just await the same in-progress resolution rather than firing a
|
|
571
|
+
// second, independent commit.
|
|
572
|
+
const ok = await switchAwayFrom();
|
|
573
|
+
if (!ok) return; // the open editor's auto-commit failed; stay put
|
|
574
|
+
}
|
|
575
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
576
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
577
|
+
if (!block) return;
|
|
578
|
+
|
|
579
|
+
const original = blockEl.innerHTML;
|
|
580
|
+
const source = extractBlockSource(lines, block);
|
|
581
|
+
|
|
582
|
+
const wrap = document.createElement('div');
|
|
583
|
+
wrap.className = 'ed-editing';
|
|
584
|
+
|
|
585
|
+
const ta = document.createElement('textarea');
|
|
586
|
+
ta.className = 'ed-raw';
|
|
587
|
+
ta.value = source;
|
|
588
|
+
|
|
589
|
+
// `restore`/`commit` are hoisted function declarations (defined further
|
|
590
|
+
// down in this closure) — referencing them here, before their textual
|
|
591
|
+
// definition, is safe. hasChanges() is what switchAwayFrom() (used by
|
|
592
|
+
// block-switch / outside-click / undo / redo) uses to decide "silently
|
|
593
|
+
// cancel" vs "auto-commit". commitNow/cancelNow are the raw-edit
|
|
594
|
+
// editor's implementation of the shared editor-object contract (see the
|
|
595
|
+
// `activeEditor` comment above) — thin wrappers over these same
|
|
596
|
+
// commit()/restore() closures used by the manual Ctrl+Enter/✓/Esc/✕ UI.
|
|
597
|
+
activeEditor = {
|
|
598
|
+
blockEl,
|
|
599
|
+
hasChanges: () => ta.value !== source,
|
|
600
|
+
commitNow: commit,
|
|
601
|
+
cancelNow: cancelAndMaybeDiscard,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const controls = document.createElement('div');
|
|
605
|
+
controls.className = 'ed-controls';
|
|
606
|
+
const commitBtn = document.createElement('button');
|
|
607
|
+
commitBtn.type = 'button';
|
|
608
|
+
commitBtn.className = 'ed-commit';
|
|
609
|
+
commitBtn.textContent = '✓';
|
|
610
|
+
const cancelBtn = document.createElement('button');
|
|
611
|
+
cancelBtn.type = 'button';
|
|
612
|
+
cancelBtn.className = 'ed-cancel';
|
|
613
|
+
cancelBtn.textContent = '✕';
|
|
614
|
+
controls.appendChild(commitBtn);
|
|
615
|
+
controls.appendChild(cancelBtn);
|
|
616
|
+
|
|
617
|
+
wrap.appendChild(ta);
|
|
618
|
+
wrap.appendChild(controls);
|
|
619
|
+
|
|
620
|
+
blockEl.innerHTML = '';
|
|
621
|
+
blockEl.appendChild(wrap);
|
|
622
|
+
autoSize(ta);
|
|
623
|
+
ta.focus();
|
|
624
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
625
|
+
|
|
626
|
+
function restore() {
|
|
627
|
+
blockEl.innerHTML = original;
|
|
628
|
+
// Only clear `activeEditor` if it's still THIS block's entry — it may
|
|
629
|
+
// already have been cleared out from under us (e.g. by rerenderAll()'s
|
|
630
|
+
// own defensive reset on a successful swap elsewhere), and this must
|
|
631
|
+
// never stomp some OTHER block's activeEditor set after this one.
|
|
632
|
+
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
633
|
+
// No re-wiring needed here (unlike the old per-block gutter): block
|
|
634
|
+
// selection / the edit bar are handled by ONE delegated `document`
|
|
635
|
+
// click listener (see wireBlockSelection() below), so this
|
|
636
|
+
// now-un-wrapped block is already clickable again by construction.
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// §10-gap fix (review): wraps restore() — restore() NEVER commits
|
|
640
|
+
// anything to `lines` (only decorative DOM), so for THIS block, any
|
|
641
|
+
// call to it (Escape, ✕, or the auto-cancel-on-unchanged-blur path in
|
|
642
|
+
// resolveOpenSession()) means `lines` still holds EXACTLY whatever was
|
|
643
|
+
// there when this editor opened. For a pristine, just-inserted code
|
|
644
|
+
// block, that's still the untouched skeleton — auto-remove it, same as
|
|
645
|
+
// every other block type's abandon path. Used at every restore() call
|
|
646
|
+
// site below so there's exactly ONE place this check lives.
|
|
647
|
+
async function cancelAndMaybeDiscard() {
|
|
648
|
+
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
|
|
649
|
+
if (wasPristineForThisBlock) pristineInsert = null;
|
|
650
|
+
restore();
|
|
651
|
+
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Returns true when the editor is resolved (committed, or a no-op
|
|
656
|
+
// commit that fell back to a cancel) — safe for a caller (switchAwayFrom
|
|
657
|
+
// included) to proceed. Returns false only when the render actually
|
|
658
|
+
// failed: the optimistic edit is rolled back from `lines`/the undo
|
|
659
|
+
// stack, but — unlike the old behavior — the editor is left OPEN with
|
|
660
|
+
// the user's text untouched (state consistency over convenience: a
|
|
661
|
+
// network hiccup must never silently discard what they typed). The
|
|
662
|
+
// failure banner is already shown by safeRerenderAll()/rerenderAll().
|
|
663
|
+
async function commit() {
|
|
664
|
+
const result = commitEdit({ lines, blocks, stack }, blockId, ta.value);
|
|
665
|
+
if (result.op === null) {
|
|
666
|
+
// §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
|
|
667
|
+
// text happens to be byte-identical to `source` is still an
|
|
668
|
+
// "unchanged" exit for THIS block — same auto-remove contract as
|
|
669
|
+
// every other restore() call site.
|
|
670
|
+
return await cancelAndMaybeDiscard();
|
|
671
|
+
}
|
|
672
|
+
const prevLines = lines;
|
|
673
|
+
lines = result.lines;
|
|
674
|
+
const ok = await safeRerenderAll();
|
|
675
|
+
if (!ok) {
|
|
676
|
+
// Roll back the optimistic edit: pop the op safeRerenderAll's
|
|
677
|
+
// failure means was never actually rendered, so `lines` stays
|
|
678
|
+
// consistent with what the server actually has. Deliberately does
|
|
679
|
+
// NOT call restore() — the editor (and the user's unsaved text)
|
|
680
|
+
// stays open and visible; see the comment above.
|
|
681
|
+
const rollback = stack.undo(lines);
|
|
682
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
// Success: rerenderAll() already replaced the whole .content subtree
|
|
686
|
+
// (this block included) and nulled `activeEditor` itself — nothing
|
|
687
|
+
// left to do here.
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
ta.addEventListener('input', () => autoSize(ta));
|
|
692
|
+
ta.addEventListener('keydown', (e) => {
|
|
693
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
694
|
+
e.preventDefault();
|
|
695
|
+
commit();
|
|
696
|
+
} else if (e.key === 'Escape') {
|
|
697
|
+
e.preventDefault();
|
|
698
|
+
cancelAndMaybeDiscard();
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
commitBtn.addEventListener('click', commit);
|
|
702
|
+
cancelBtn.addEventListener('click', cancelAndMaybeDiscard);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Phase-2 Task 3: paragraph / heading WYSIWYG editing ────────────────
|
|
706
|
+
// A block's rendered content is the .ed-block's single element child
|
|
707
|
+
// (see lib/md2doc.js's editMode wrapper: `<div class="ed-block"
|
|
708
|
+
// ...>${inner}</div>` where `inner` is exactly one <p>/<h#>/... tag).
|
|
709
|
+
// Per-li exception (Task 6 / Phase 4): for data-block-type="li" the
|
|
710
|
+
// .ed-block IS the <li> itself — its editable content is the child
|
|
711
|
+
// <div class="ed-li-text"> (Task 4), not firstElementChild (which would
|
|
712
|
+
// land on the optional .ed-li-check span or a nested <ul>/<ol>).
|
|
713
|
+
function blockContentEl(blockEl) {
|
|
714
|
+
if (blockEl.getAttribute && blockEl.getAttribute('data-block-type') === 'li') {
|
|
715
|
+
// Walk childNodes for the DIV with class ed-li-text (Task 4 shape).
|
|
716
|
+
const children = blockEl.childNodes;
|
|
717
|
+
for (let i = 0; i < children.length; i++) {
|
|
718
|
+
const n = children[i];
|
|
719
|
+
if (n.nodeType === 1 && n.nodeName === 'DIV' &&
|
|
720
|
+
n.classList && n.classList.contains('ed-li-text')) {
|
|
721
|
+
return n;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
return blockEl.firstElementChild;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Headings render with a trailing `<a class="heading-anchor">#</a>`
|
|
730
|
+
// permalink icon INSIDE the <h#> tag (lib/md2doc.js's renderer.heading) —
|
|
731
|
+
// presentational chrome, not authored content. It must never reach the
|
|
732
|
+
// inline serializer: an anchor whose text is the literal "#" doesn't match
|
|
733
|
+
// the citation shape (`/^\[.*\]$/`), so inline-md.js's serializeAnchor()
|
|
734
|
+
// would emit it as a bogus trailing markdown link on every heading commit
|
|
735
|
+
// instead of flagging it unsupported. Mutates `el` in place (safe to call
|
|
736
|
+
// on a throwaway clone for the non-mutating eligibility probe below, or on
|
|
737
|
+
// the real live element right before it becomes contenteditable).
|
|
738
|
+
function stripHeadingAnchor(el) {
|
|
739
|
+
const a = el.querySelector(':scope > a.heading-anchor');
|
|
740
|
+
if (a) a.remove();
|
|
741
|
+
return el;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Non-mutating eligibility check used at the ✎ button's routing decision —
|
|
745
|
+
// clones the content element so a block that turns out ineligible (falls
|
|
746
|
+
// back to raw-edit) is never touched.
|
|
747
|
+
function canWysiwygForBlock(blockEl, blockType) {
|
|
748
|
+
const el = blockContentEl(blockEl);
|
|
749
|
+
if (!el) return false;
|
|
750
|
+
const probe = blockType === 'heading' ? stripHeadingAnchor(el.cloneNode(true)) : el;
|
|
751
|
+
return inlineMd.canWysiwyg(probe);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Phase-2 Task 5: same "check before ever opening the editor" contract as
|
|
755
|
+
// canWysiwygForBlock() above, for a <table> element — Global Constraint:
|
|
756
|
+
// any single unsupported cell degrades the WHOLE table to raw-edit, never
|
|
757
|
+
// a partial/half-broken cell session. No cloneNode() needed here (unlike
|
|
758
|
+
// the heading case above): serializeTable() never mutates its input.
|
|
759
|
+
function canWysiwygForTable(tableEl) {
|
|
760
|
+
return !!tableEl && tableMd.serializeTable(tableEl).unsupported.length === 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Task 6 (Phase 4): per-li eligibility check — build a one-item probe
|
|
764
|
+
// UL/OL containing ONLY this li's non-list children (the .ed-li-check span
|
|
765
|
+
// and the .ed-li-text div), then serialize it. Returns false when any
|
|
766
|
+
// inline content is unsupported, so that li stays unarmed without affecting
|
|
767
|
+
// its siblings. Uses the live li's own parent tag (UL or OL) so an ordered
|
|
768
|
+
// task-item probes correctly as an ordered list.
|
|
769
|
+
function canWysiwygForLi(liEl) {
|
|
770
|
+
if (!liEl) return false;
|
|
771
|
+
const parentTag = liEl.parentElement ? liEl.parentElement.nodeName : 'UL';
|
|
772
|
+
const probe = document.createElement(parentTag);
|
|
773
|
+
const liClone = liEl.cloneNode(false); // shallow: no nested ul/ol
|
|
774
|
+
const kids = liEl.childNodes;
|
|
775
|
+
for (let i = 0; i < kids.length; i++) {
|
|
776
|
+
const k = kids[i];
|
|
777
|
+
// Copy non-list children only (the check span and the text div).
|
|
778
|
+
if (k.nodeName !== 'UL' && k.nodeName !== 'OL') {
|
|
779
|
+
liClone.appendChild(k.cloneNode(true));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
probe.appendChild(liClone);
|
|
783
|
+
return listMd.serializeList(probe).unsupported.length === 0;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Walk up from `el` to find the outermost UL/OL whose parent is NOT a <li>
|
|
787
|
+
// (i.e. the list-run root — the UL/OL that is directly inside .ed-block or
|
|
788
|
+
// the document, not a nested sub-list inside another li).
|
|
789
|
+
function listRunRootOf(el) {
|
|
790
|
+
let cur = el;
|
|
791
|
+
let root = null;
|
|
792
|
+
while (cur) {
|
|
793
|
+
if (cur.nodeName === 'UL' || cur.nodeName === 'OL') {
|
|
794
|
+
if (!cur.parentElement || cur.parentElement.nodeName !== 'LI') {
|
|
795
|
+
root = cur;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
cur = cur.parentElement;
|
|
799
|
+
}
|
|
800
|
+
return root;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Returns { startLine, endLine, firstId } from the first and last .ed-block
|
|
804
|
+
// li descendants of rootEl, looked up in state.blocks by their data-block-id
|
|
805
|
+
// (document order is monotonic — Task 3 asserts it — so first/last suffices).
|
|
806
|
+
function runRangeOf(state, rootEl) {
|
|
807
|
+
const liEls = Array.prototype.slice.call(rootEl.querySelectorAll('.ed-block'));
|
|
808
|
+
if (!liEls.length) return null;
|
|
809
|
+
const firstId = Number(liEls[0].getAttribute('data-block-id'));
|
|
810
|
+
const lastId = Number(liEls[liEls.length - 1].getAttribute('data-block-id'));
|
|
811
|
+
const firstBlock = state.blocks.find((b) => b.id === firstId);
|
|
812
|
+
const lastBlock = state.blocks.find((b) => b.id === lastId);
|
|
813
|
+
if (!firstBlock || !lastBlock) return null;
|
|
814
|
+
return { startLine: firstBlock.startLine, endLine: lastBlock.endLine, firstId };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function placeCaretAtEnd(el) {
|
|
818
|
+
const range = document.createRange();
|
|
819
|
+
range.selectNodeContents(el);
|
|
820
|
+
range.collapse(false);
|
|
821
|
+
const sel = window.getSelection();
|
|
822
|
+
sel.removeAllRanges();
|
|
823
|
+
sel.addRange(range);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Paste handler support: insert plain text at the caret via Range surgery,
|
|
827
|
+
// keeping the serializer's input domain closed to plain text + the inline
|
|
828
|
+
// elements it itself produces (bold/italic/code/links/br) — see the brief's
|
|
829
|
+
// "Paste" rule.
|
|
830
|
+
//
|
|
831
|
+
// Final-review Finding 1: pasted text containing a newline used to land
|
|
832
|
+
// verbatim in ONE text node, so a paste into a table cell produced a text
|
|
833
|
+
// node whose textContent itself contained '\n' — table-md.js's
|
|
834
|
+
// serializeRow() had no reason to expect that (a cell newline was only
|
|
835
|
+
// ever supposed to arrive as a real <br> node, same as Shift+Enter's
|
|
836
|
+
// insertBrAtCaret() below) and emitted it raw, splitting one table row
|
|
837
|
+
// into a spec-forbidden orphan cell line. Split on any newline sequence
|
|
838
|
+
// and insert a real <br> element between segments — the SAME DIV/BR
|
|
839
|
+
// policy walkChildren() (inline-md.js) already round-trips, so this is
|
|
840
|
+
// consistent with how Shift+Enter's own <br> already behaves, not a new
|
|
841
|
+
// code path. table-md.js also gained a defense-in-depth backstop for any
|
|
842
|
+
// other caller that still lands a raw '\n' in a text node (see
|
|
843
|
+
// escapeNewlines() there) — this is the primary fix, that's the belt.
|
|
844
|
+
function insertTextAtCaret(text) {
|
|
845
|
+
const sel = window.getSelection();
|
|
846
|
+
if (!sel.rangeCount) return;
|
|
847
|
+
const range = sel.getRangeAt(0);
|
|
848
|
+
range.deleteContents();
|
|
849
|
+
const segments = String(text).split(/\r\n|\r|\n/);
|
|
850
|
+
segments.forEach((seg, i) => {
|
|
851
|
+
if (i > 0) {
|
|
852
|
+
const br = document.createElement('br');
|
|
853
|
+
range.insertNode(br);
|
|
854
|
+
range.setStartAfter(br);
|
|
855
|
+
range.setEndAfter(br);
|
|
856
|
+
}
|
|
857
|
+
const node = document.createTextNode(seg);
|
|
858
|
+
range.insertNode(node);
|
|
859
|
+
range.setStartAfter(node);
|
|
860
|
+
range.setEndAfter(node);
|
|
861
|
+
});
|
|
862
|
+
sel.removeAllRanges();
|
|
863
|
+
sel.addRange(range);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Shift+Enter support: insert a literal <br> at the caret via Range
|
|
867
|
+
// surgery — inline-md.js's walkChildren() serializes a <br> node straight
|
|
868
|
+
// back to `<br>` markdown, so this round-trips without going through the
|
|
869
|
+
// DIV-boundary path (that's for browsers' own line-split artifacts, not
|
|
870
|
+
// something this editor ever produces itself).
|
|
871
|
+
function insertBrAtCaret() {
|
|
872
|
+
const sel = window.getSelection();
|
|
873
|
+
if (!sel.rangeCount) return;
|
|
874
|
+
const range = sel.getRangeAt(0);
|
|
875
|
+
range.deleteContents();
|
|
876
|
+
const br = document.createElement('br');
|
|
877
|
+
range.insertNode(br);
|
|
878
|
+
range.setStartAfter(br);
|
|
879
|
+
range.setEndAfter(br);
|
|
880
|
+
sel.removeAllRanges();
|
|
881
|
+
sel.addRange(range);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Heading ± buttons on the bar: a pure source-level transform (just the
|
|
885
|
+
// leading `#` run) via the SAME commitEdit()/replaceLines() pipeline as
|
|
886
|
+
// every other edit, then a full re-render — deliberately independent of
|
|
887
|
+
// whatever the inline serializer thinks of the heading's prose content.
|
|
888
|
+
// switchAwayFrom() first resolves any editor that's currently open on this
|
|
889
|
+
// (or another) block, same precondition as undo()/redo() below, so this
|
|
890
|
+
// never races a concurrent commit or operates on stale `lines`/`blocks`.
|
|
891
|
+
async function changeHeadingDepth(blockEl, delta) {
|
|
892
|
+
if (!blockEl) return;
|
|
893
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
894
|
+
// Commits (never discards) whatever burst/editor is open first — same
|
|
895
|
+
// precondition undo()/redo() use below, so this never races a concurrent
|
|
896
|
+
// commit or operates on stale `lines`/`blocks`. Task 2 (Phase 3): this
|
|
897
|
+
// now also resolves an open always-on WYSIWYG burst on THIS same block
|
|
898
|
+
// (the ⠿ menu's ± buttons can be clicked while its own heading is
|
|
899
|
+
// mid-edit) via switchAwayFrom()'s extended resolveOpenSession().
|
|
900
|
+
const ok = await switchAwayFrom();
|
|
901
|
+
if (!ok) return;
|
|
902
|
+
// Final-review Finding 5c (Important): with 5a's mousedown
|
|
903
|
+
// preventDefault() now keeping the ⠿ click from blurring a dirty
|
|
904
|
+
// burst, THIS is where that same dirty burst (on this heading's own
|
|
905
|
+
// block) actually gets resolved — the `switchAwayFrom()` above commits
|
|
906
|
+
// it, whose rerenderAll() swaps the WHOLE `.content` subtree, detaching
|
|
907
|
+
// the ORIGINAL `blockEl` this function was called with. The old
|
|
908
|
+
// `!document.body.contains(blockEl)` check treated that as "gone,
|
|
909
|
+
// nothing to do" and silently no-opped — which is exactly the
|
|
910
|
+
// dirty-heading-then-± regression 5a's fix would otherwise introduce
|
|
911
|
+
// (before 5a, the ± click's OWN mousedown had already committed the
|
|
912
|
+
// burst and swapped the DOM before this ran, so `blockEl` was ALREADY
|
|
913
|
+
// stale on every such click, just via a different, race-dependent
|
|
914
|
+
// path — this bug pre-dates 5a, 5a just makes it deterministic). Same
|
|
915
|
+
// stale-node recovery the focusin listener's own re-resolve branch
|
|
916
|
+
// uses above: re-query the LIVE block by id rather than trusting the
|
|
917
|
+
// original reference.
|
|
918
|
+
if (!document.body.contains(blockEl)) {
|
|
919
|
+
blockEl = document.querySelector('.ed-block[data-block-id="' + blockId + '"]');
|
|
920
|
+
if (!blockEl) return;
|
|
921
|
+
}
|
|
922
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
923
|
+
if (!block) return;
|
|
924
|
+
const curLine = lines[block.startLine - 1];
|
|
925
|
+
const curDepth = headingDepthOf(curLine);
|
|
926
|
+
const newDepth = Math.max(1, Math.min(6, curDepth + delta));
|
|
927
|
+
if (newDepth === curDepth) return;
|
|
928
|
+
const newLine = withHeadingDepth(curLine, newDepth);
|
|
929
|
+
const result = commitEdit({ lines, blocks, stack }, blockId, newLine);
|
|
930
|
+
if (result.op === null) return;
|
|
931
|
+
const prevLines = lines;
|
|
932
|
+
lines = result.lines;
|
|
933
|
+
const okRender = await safeRerenderAll();
|
|
934
|
+
if (!okRender) {
|
|
935
|
+
const rollback = stack.undo(lines);
|
|
936
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
|
|
941
|
+
// Retires the Phase-2 click-select-then-✎ flow for paragraph/heading
|
|
942
|
+
// blocks: every WYSIWYG-eligible one is contenteditable from the moment
|
|
943
|
+
// it lands in the DOM (armEditables() below, run once at load and again
|
|
944
|
+
// after every rerenderAll() swap). Click = native caret placement — no
|
|
945
|
+
// "open" step. Focusing such a surface starts a "burst" (a short-lived
|
|
946
|
+
// undo/redo scope backed by lib/editor/history.js's createBurstHistory());
|
|
947
|
+
// focusing away from it resolves the burst exactly like the old
|
|
948
|
+
// activeEditor did (commit if changed, silently drop if not) via
|
|
949
|
+
// switchAwayFrom()/resolveOpenSession() above — table cells and the raw
|
|
950
|
+
// textarea editor are untouched, they keep using `activeEditor` as before.
|
|
951
|
+
//
|
|
952
|
+
// Listener discipline (the brief's hard requirement, institutionalizing
|
|
953
|
+
// the Task-3-P2 listener-leak lesson): every one of these surfaces is
|
|
954
|
+
// armed identically and wired through exactly ONE delegated document-level
|
|
955
|
+
// focusin / focusout / keydown / paste / input listener set (registered
|
|
956
|
+
// once, at the bottom of this file) gated by the `.ed-wys-armed` class —
|
|
957
|
+
// never a per-block addEventListener that could re-stack across repeated
|
|
958
|
+
// open/close cycles.
|
|
959
|
+
function blockDepthOf(blockType, editEl) {
|
|
960
|
+
return blockType === 'heading' ? Number(editEl.tagName.slice(1)) : null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Arms every WYSIWYG-eligible paragraph/heading/list/table in `root` as an
|
|
964
|
+
// always-on editable surface, and gives every block (eligible or degraded
|
|
965
|
+
// alike) a ⠿ handle in its left gutter. Run once at load and again after
|
|
966
|
+
// every rerenderAll() swap (fresh DOM, nothing armed yet).
|
|
967
|
+
// Idempotent-by-construction: only ever called against a freshly-rendered
|
|
968
|
+
// subtree that has never been armed before.
|
|
969
|
+
function armEditables(root) {
|
|
970
|
+
const blockEls = Array.prototype.slice.call(root.querySelectorAll('.ed-block'));
|
|
971
|
+
blockEls.forEach((blockEl) => {
|
|
972
|
+
const blockType = blockEl.getAttribute('data-block-type');
|
|
973
|
+
const editEl = blockContentEl(blockEl);
|
|
974
|
+
if (editEl && (blockType === 'paragraph' || blockType === 'heading') &&
|
|
975
|
+
canWysiwygForBlock(blockEl, blockType)) {
|
|
976
|
+
// Heading permalink anchors are presentational chrome, never
|
|
977
|
+
// authored content (see stripHeadingAnchor()'s own comment) — strip
|
|
978
|
+
// them at arm time so they never become part of what's typed/
|
|
979
|
+
// selected/serialized. Never re-inserted by hand: the markdown they
|
|
980
|
+
// came from never referenced them, and the NEXT full rerenderAll()
|
|
981
|
+
// regenerates them fresh from the server (then immediately strips
|
|
982
|
+
// them again on re-arm) — "restored in serialization" in the brief
|
|
983
|
+
// refers to exactly this round trip, not a DOM patch-back here.
|
|
984
|
+
if (blockType === 'heading') stripHeadingAnchor(editEl);
|
|
985
|
+
editEl.setAttribute('contenteditable', 'true');
|
|
986
|
+
editEl.classList.add('ed-wys-armed');
|
|
987
|
+
} else if (blockType === 'li') {
|
|
988
|
+
// Task 6 (Phase 4): per-li arming. Each <li class="ed-block"> is
|
|
989
|
+
// armed independently: only its own .ed-li-text div becomes
|
|
990
|
+
// contenteditable when canWysiwygForLi holds, so one unsupported item
|
|
991
|
+
// does not degrade its siblings.
|
|
992
|
+
if (editEl && canWysiwygForLi(blockEl)) {
|
|
993
|
+
editEl.setAttribute('contenteditable', 'true');
|
|
994
|
+
editEl.classList.add('ed-wys-armed');
|
|
995
|
+
}
|
|
996
|
+
// MUST return here — a <li> gets NO ⠿/+ gutter chrome (overlay
|
|
997
|
+
// chrome is P4). The unconditional appendChild calls below would
|
|
998
|
+
// inject <button> children into the <li>, which list-md.js would
|
|
999
|
+
// classify as content and inline-md.js would flag 'BUTTON' unsupported,
|
|
1000
|
+
// silently degrading every list item. Do NOT remove this return.
|
|
1001
|
+
return;
|
|
1002
|
+
} else if (editEl && blockType === 'table' && canWysiwygForTable(editEl)) {
|
|
1003
|
+
// Task 5 (Phase 3): table cells armed PERMANENTLY at arm time
|
|
1004
|
+
// (Global Constraint — replaces Phase-2's click-to-open session).
|
|
1005
|
+
// Every TH/TD becomes its OWN contenteditable surface (class
|
|
1006
|
+
// 'ed-wys-cell'), unlike the list/paragraph single-content-root
|
|
1007
|
+
// arming above, because a table has many independently-editable
|
|
1008
|
+
// cells — but the BURST still spans the WHOLE table
|
|
1009
|
+
// (currentBurst.editEl === the <table> element, never any one
|
|
1010
|
+
// cell; see startTableBurst() below), so Tab/click between cells
|
|
1011
|
+
// never ends it, only leaving the TABLE does. The TABLE root
|
|
1012
|
+
// itself gets the marker class 'ed-wys-table' (never
|
|
1013
|
+
// contenteditable itself — a <table> can't sensibly host a caret)
|
|
1014
|
+
// so the click delegator and the hover-insert overlay below can
|
|
1015
|
+
// recognize an armed table without walking its cells.
|
|
1016
|
+
editEl.classList.add('ed-wys-table');
|
|
1017
|
+
tableCellsOf(editEl).forEach((cell) => {
|
|
1018
|
+
cell.setAttribute('contenteditable', 'true');
|
|
1019
|
+
cell.classList.add('ed-wys-cell');
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
// Every block (armed or degraded — table included, since this task
|
|
1023
|
+
// retires the old `if (blockType === 'table') return` early exit)
|
|
1024
|
+
// gets the ⠿ handle — a real per-block DOM node (not a listener: see
|
|
1025
|
+
// buildGutterHandle()'s comment for why that's fine), appended AFTER
|
|
1026
|
+
// the content element so blockContentEl()'s firstElementChild lookup
|
|
1027
|
+
// is unaffected. §10-gap fix: and a + insert button right alongside
|
|
1028
|
+
// it, same non-listener node shape, same reason.
|
|
1029
|
+
blockEl.appendChild(buildGutterInsertButton());
|
|
1030
|
+
blockEl.appendChild(buildGutterHandle());
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// A fresh ⠿ button per block — deliberately NOT wired with its own
|
|
1035
|
+
// addEventListener (that would be exactly the per-block listener the
|
|
1036
|
+
// brief's discipline rule forbids); the delegated document `click`
|
|
1037
|
+
// listener (wireBlockSelection() below) recognizes '.ed-handle' and
|
|
1038
|
+
// routes the click, so this node itself carries no JS at all.
|
|
1039
|
+
function buildGutterHandle() {
|
|
1040
|
+
const el = document.createElement('button');
|
|
1041
|
+
el.type = 'button';
|
|
1042
|
+
el.className = 'ed-handle';
|
|
1043
|
+
el.textContent = '⠿';
|
|
1044
|
+
el.setAttribute('aria-label', '區塊選項');
|
|
1045
|
+
// Deliberately NOT wired with its own addEventListener here (see the
|
|
1046
|
+
// paragraph above) — including for Final-review Finding 5a's mousedown
|
|
1047
|
+
// preventDefault() (see wireBlockSelection()'s delegated 'mousedown'
|
|
1048
|
+
// listener below for that fix): a per-node listener attached HERE would
|
|
1049
|
+
// NOT survive openRawEditor()'s restore() (`blockEl.innerHTML =
|
|
1050
|
+
// original`, a plain string re-parse that recreates this button with
|
|
1051
|
+
// none of its own JS re-attached) the way the delegated 'click'
|
|
1052
|
+
// listener already does — the exact hazard this file's discipline rule
|
|
1053
|
+
// exists to prevent.
|
|
1054
|
+
return el;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// The single shared ⠿ menu (heading ± / MD 原始碼 / close) — built once,
|
|
1058
|
+
// moved into whichever block's DOM the user opened it on, same pattern as
|
|
1059
|
+
// `selToolbar` elsewhere in this file. `gutterMenuBlockEl` names which
|
|
1060
|
+
// block it's currently open for.
|
|
1061
|
+
let gutterMenuBlockEl = null;
|
|
1062
|
+
let gutterMenuMinus, gutterMenuPlus;
|
|
1063
|
+
|
|
1064
|
+
function buildGutterMenu() {
|
|
1065
|
+
const el = document.createElement('div');
|
|
1066
|
+
el.className = 'ed-handle-menu';
|
|
1067
|
+
|
|
1068
|
+
gutterMenuMinus = document.createElement('button');
|
|
1069
|
+
gutterMenuMinus.type = 'button';
|
|
1070
|
+
gutterMenuMinus.className = 'ed-handle-menu-btn';
|
|
1071
|
+
gutterMenuMinus.textContent = '−';
|
|
1072
|
+
gutterMenuMinus.setAttribute('aria-label', 'Decrease heading level');
|
|
1073
|
+
gutterMenuMinus.addEventListener('click', (e) => {
|
|
1074
|
+
e.stopPropagation();
|
|
1075
|
+
const blockEl = gutterMenuBlockEl;
|
|
1076
|
+
closeGutterMenu();
|
|
1077
|
+
changeHeadingDepth(blockEl, -1);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
gutterMenuPlus = document.createElement('button');
|
|
1081
|
+
gutterMenuPlus.type = 'button';
|
|
1082
|
+
gutterMenuPlus.className = 'ed-handle-menu-btn';
|
|
1083
|
+
gutterMenuPlus.textContent = '+';
|
|
1084
|
+
gutterMenuPlus.setAttribute('aria-label', 'Increase heading level');
|
|
1085
|
+
gutterMenuPlus.addEventListener('click', (e) => {
|
|
1086
|
+
e.stopPropagation();
|
|
1087
|
+
const blockEl = gutterMenuBlockEl;
|
|
1088
|
+
closeGutterMenu();
|
|
1089
|
+
changeHeadingDepth(blockEl, 1);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
const mdBtn = document.createElement('button');
|
|
1093
|
+
mdBtn.type = 'button';
|
|
1094
|
+
mdBtn.className = 'ed-handle-menu-btn';
|
|
1095
|
+
mdBtn.textContent = 'MD 原始碼';
|
|
1096
|
+
mdBtn.setAttribute('aria-label', 'Switch to raw markdown edit');
|
|
1097
|
+
mdBtn.addEventListener('click', (e) => {
|
|
1098
|
+
e.stopPropagation();
|
|
1099
|
+
const blockEl = gutterMenuBlockEl;
|
|
1100
|
+
closeGutterMenu();
|
|
1101
|
+
openRawViaGutter(blockEl);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
|
|
1105
|
+
// unchanged (that function was already fully block-type-agnostic —
|
|
1106
|
+
// it only ever reads block.startLine/endLine off `state.blocks`,
|
|
1107
|
+
// nothing list-specific — so "generalizing" it to any block type is
|
|
1108
|
+
// just calling it from here too, not touching its implementation) via
|
|
1109
|
+
// deleteBlockViaGutter() below, which resolves any open burst first
|
|
1110
|
+
// (requirement: structural ops always go through switchAwayFrom()).
|
|
1111
|
+
const deleteBtn = document.createElement('button');
|
|
1112
|
+
deleteBtn.type = 'button';
|
|
1113
|
+
deleteBtn.className = 'ed-handle-menu-btn';
|
|
1114
|
+
deleteBtn.textContent = '刪除';
|
|
1115
|
+
deleteBtn.setAttribute('aria-label', 'Delete this block');
|
|
1116
|
+
deleteBtn.addEventListener('click', (e) => {
|
|
1117
|
+
e.stopPropagation();
|
|
1118
|
+
const blockEl = gutterMenuBlockEl;
|
|
1119
|
+
closeGutterMenu();
|
|
1120
|
+
deleteBlockViaGutter(blockEl);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const closeBtn = document.createElement('button');
|
|
1124
|
+
closeBtn.type = 'button';
|
|
1125
|
+
closeBtn.className = 'ed-handle-menu-btn';
|
|
1126
|
+
closeBtn.textContent = '✕';
|
|
1127
|
+
closeBtn.setAttribute('aria-label', 'Close menu');
|
|
1128
|
+
closeBtn.addEventListener('click', (e) => {
|
|
1129
|
+
e.stopPropagation();
|
|
1130
|
+
closeGutterMenu();
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
el.appendChild(gutterMenuMinus);
|
|
1134
|
+
el.appendChild(gutterMenuPlus);
|
|
1135
|
+
el.appendChild(mdBtn);
|
|
1136
|
+
el.appendChild(deleteBtn);
|
|
1137
|
+
el.appendChild(closeBtn);
|
|
1138
|
+
return el;
|
|
1139
|
+
}
|
|
1140
|
+
const gutterMenu = buildGutterMenu();
|
|
1141
|
+
|
|
1142
|
+
function closeGutterMenu() {
|
|
1143
|
+
gutterMenu.remove();
|
|
1144
|
+
gutterMenuBlockEl = null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function toggleGutterMenu(blockEl) {
|
|
1148
|
+
if (!blockEl) return;
|
|
1149
|
+
if (gutterMenuBlockEl === blockEl) { closeGutterMenu(); return; }
|
|
1150
|
+
// §10-gap fix: the ⠿ menu and the + insert menu are mutually exclusive
|
|
1151
|
+
// — both are singleton nodes appended as a CHILD of whichever block
|
|
1152
|
+
// they're open for (same idiom), so opening one while the other is open
|
|
1153
|
+
// on a DIFFERENT block would otherwise leave two floating menus up at
|
|
1154
|
+
// once. closeInsertMenu() is idempotent (safe even when nothing is open).
|
|
1155
|
+
closeInsertMenu();
|
|
1156
|
+
gutterMenuBlockEl = blockEl;
|
|
1157
|
+
const blockType = blockEl.getAttribute('data-block-type');
|
|
1158
|
+
const isHeading = blockType === 'heading';
|
|
1159
|
+
gutterMenuMinus.hidden = !isHeading;
|
|
1160
|
+
gutterMenuPlus.hidden = !isHeading;
|
|
1161
|
+
blockEl.appendChild(gutterMenu);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ── §10-gap fix: block-level INSERT ─────────────────────────────────────
|
|
1165
|
+
// A fresh + button per block, sat NEXT TO the ⠿ handle in the left gutter
|
|
1166
|
+
// (Notion order: + then ⠿, + further from the content — see the CSS in
|
|
1167
|
+
// lib/md2doc.js). Same "no per-node listener" discipline as
|
|
1168
|
+
// buildGutterHandle() above, for the same reason (openRawEditor()'s
|
|
1169
|
+
// restore() re-parses the block's innerHTML from a plain string, which
|
|
1170
|
+
// would silently drop any listener attached directly here).
|
|
1171
|
+
function buildGutterInsertButton() {
|
|
1172
|
+
const el = document.createElement('button');
|
|
1173
|
+
el.type = 'button';
|
|
1174
|
+
el.className = 'ed-insert';
|
|
1175
|
+
el.textContent = '+';
|
|
1176
|
+
el.setAttribute('aria-label', '插入區塊');
|
|
1177
|
+
return el;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// The single shared + insert menu — same singleton/move-into-block idiom
|
|
1181
|
+
// as `gutterMenu` above. `insertMenuBlockEl` names which block it's open
|
|
1182
|
+
// for (the block the new one will be inserted BELOW).
|
|
1183
|
+
let insertMenuBlockEl = null;
|
|
1184
|
+
|
|
1185
|
+
const INSERT_KIND_LABELS = [
|
|
1186
|
+
['paragraph', '段落'],
|
|
1187
|
+
['heading', '標題'],
|
|
1188
|
+
['list', '清單'],
|
|
1189
|
+
['table', '表格'],
|
|
1190
|
+
['code', '程式碼'],
|
|
1191
|
+
];
|
|
1192
|
+
|
|
1193
|
+
function buildInsertMenu() {
|
|
1194
|
+
const el = document.createElement('div');
|
|
1195
|
+
el.className = 'ed-insert-menu';
|
|
1196
|
+
INSERT_KIND_LABELS.forEach(([kind, label]) => {
|
|
1197
|
+
const btn = document.createElement('button');
|
|
1198
|
+
btn.type = 'button';
|
|
1199
|
+
btn.className = 'ed-insert-menu-btn';
|
|
1200
|
+
btn.textContent = label;
|
|
1201
|
+
btn.setAttribute('aria-label', 'Insert ' + kind + ' block below');
|
|
1202
|
+
btn.addEventListener('click', (e) => {
|
|
1203
|
+
e.stopPropagation();
|
|
1204
|
+
const blockEl = insertMenuBlockEl;
|
|
1205
|
+
closeInsertMenu();
|
|
1206
|
+
insertBlockBelow(blockEl, kind);
|
|
1207
|
+
});
|
|
1208
|
+
el.appendChild(btn);
|
|
1209
|
+
});
|
|
1210
|
+
return el;
|
|
1211
|
+
}
|
|
1212
|
+
const insertMenu = buildInsertMenu();
|
|
1213
|
+
|
|
1214
|
+
function closeInsertMenu() {
|
|
1215
|
+
insertMenu.remove();
|
|
1216
|
+
insertMenuBlockEl = null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function toggleInsertMenu(blockEl) {
|
|
1220
|
+
if (!blockEl) return;
|
|
1221
|
+
if (insertMenuBlockEl === blockEl) { closeInsertMenu(); return; }
|
|
1222
|
+
// Mutual exclusion with the ⠿ menu — see toggleGutterMenu()'s own
|
|
1223
|
+
// comment for why. closeGutterMenu() is idempotent.
|
|
1224
|
+
closeGutterMenu();
|
|
1225
|
+
insertMenuBlockEl = blockEl;
|
|
1226
|
+
blockEl.appendChild(insertMenu);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// The new-block skeletons — deliberately minimal, matching the brief's
|
|
1230
|
+
// exact shapes for 段落/標題/清單/表格/程式碼, with one deviation forced by
|
|
1231
|
+
// `marked`'s own lexer: a bare `- ` (marker + trailing space, nothing
|
|
1232
|
+
// else) does NOT lex as a `list` token — marked only recognizes a list
|
|
1233
|
+
// item once it has SOME body content, so `- ` alone degrades to a plain
|
|
1234
|
+
// `paragraph` token (verified against marked 14.1.4: `marked.lexer('- ')`
|
|
1235
|
+
// -> `[{type:'paragraph', raw:'- ', ...}]`, while `marked.lexer('-')` ->
|
|
1236
|
+
// `[{type:'list', ...}]`). Using the brief's literal `- ` would silently
|
|
1237
|
+
// insert a paragraph typed "- " instead of an actual empty list block, so
|
|
1238
|
+
// this uses the bare marker `-` instead — verified to lex as `list` with
|
|
1239
|
+
// one empty `<li>`.
|
|
1240
|
+
//
|
|
1241
|
+
// The 段落 skeleton similarly can't be a truly empty line: a blank line by
|
|
1242
|
+
// itself is consumed by marked's lexer as a `space` token between
|
|
1243
|
+
// neighboring blocks, never becomes its own `paragraph` token, and would
|
|
1244
|
+
// leave the + menu unable to find (or focus) any block at all — see
|
|
1245
|
+
// commitBlockInsertion()'s newStartLine contract, which callers use to
|
|
1246
|
+
// locate the inserted block in the server's recomputed block list.
|
|
1247
|
+
// U+200B (zero-width space) is real, non-whitespace-per-`\s` text that
|
|
1248
|
+
// marked DOES lex as its own paragraph, and renders as `<p></p>` —
|
|
1249
|
+
// visually empty. focusInsertedBlock() below selects that single
|
|
1250
|
+
// character so an immediate keystroke replaces it, matching the "empty
|
|
1251
|
+
// paragraph to type into" intent; if the user commits without typing at
|
|
1252
|
+
// all, the ZWSP is what ends up on disk (a known, documented trade-off —
|
|
1253
|
+
// see the phase report).
|
|
1254
|
+
//
|
|
1255
|
+
// 程式碼: a bare two-line fence pair (```/```, nothing between) is what
|
|
1256
|
+
// "fence pair with empty body" reads as most literally, and DOES lex as
|
|
1257
|
+
// an empty code block (marked.lexer('```\n```') -> [{type:'code',
|
|
1258
|
+
// text:''}]) — but it gives the caret nowhere to land BETWEEN the fences:
|
|
1259
|
+
// there is no third line there. focusInsertedBlock() below places the
|
|
1260
|
+
// raw-editor caret right after the opening fence's newline; with only two
|
|
1261
|
+
// lines that position is the very START of the closing fence's own line,
|
|
1262
|
+
// so typing lands immediately before the closing ``` with no line break
|
|
1263
|
+
// of its own (`` ```typed``` `` on one line — verified via a failing
|
|
1264
|
+
// browser probe). A three-line fence with one blank line between them
|
|
1265
|
+
// (still `text: ''` per marked — verified) gives that line to land on.
|
|
1266
|
+
const BLOCK_SKELETONS = {
|
|
1267
|
+
paragraph: [''],
|
|
1268
|
+
heading: ['## '],
|
|
1269
|
+
list: ['-'],
|
|
1270
|
+
table: ['| A | B |', '|---|---|', '| | |'],
|
|
1271
|
+
code: ['```', '', '```'],
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
// Selects the entirety of `el`'s content (used right after focusing a
|
|
1275
|
+
// freshly-inserted, placeholder-only block) so the user's very first
|
|
1276
|
+
// keystroke replaces the placeholder instead of being inserted next to
|
|
1277
|
+
// it. A no-op-equivalent (nothing to select) on the genuinely-empty
|
|
1278
|
+
// heading/list/table skeletons; load-bearing only for the paragraph
|
|
1279
|
+
// skeleton's ZWSP placeholder (see BLOCK_SKELETONS above).
|
|
1280
|
+
function focusAndSelectAll(el) {
|
|
1281
|
+
el.focus();
|
|
1282
|
+
const range = document.createRange();
|
|
1283
|
+
range.selectNodeContents(el);
|
|
1284
|
+
const sel = window.getSelection();
|
|
1285
|
+
sel.removeAllRanges();
|
|
1286
|
+
sel.addRange(range);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Locates and focuses the block a commitBlockInsertion()+rerenderAll()
|
|
1290
|
+
// pair just created, by the `newStartLine` the commit computed BEFORE the
|
|
1291
|
+
// render (the server-recomputed `blocks` array — reassigned by
|
|
1292
|
+
// rerenderAll() itself — is the only place that new block gets an id, so
|
|
1293
|
+
// matching by its known startLine is the only way back to it).
|
|
1294
|
+
// §10-gap fix (review): auto-removes the block `pristineInsert` currently
|
|
1295
|
+
// points at — called from every "this block's edit surface just
|
|
1296
|
+
// resolved" path (see `pristineInsert`'s own comment for the full list)
|
|
1297
|
+
// once that path has confirmed the block's content is STILL
|
|
1298
|
+
// byte-identical to what was inserted. Reverses commitBlockInsertion()'s
|
|
1299
|
+
// own op directly via UndoStack.discardTop() (lib/editor/lineops.js)
|
|
1300
|
+
// rather than committing a SEPARATE removal — the insert op is popped
|
|
1301
|
+
// and its exact line-range reversed, so the net effect is byte-identical
|
|
1302
|
+
// to "the insert never happened": zero new undo-stack entries, not two
|
|
1303
|
+
// ops that cancel out (chosen per the review's explicit preference,
|
|
1304
|
+
// verified against UndoStack's shape — discardTop() never touches
|
|
1305
|
+
// `_undone`, so it can't disturb an unrelated redo trail either).
|
|
1306
|
+
// Returns true/false with the SAME contract as every other resolution
|
|
1307
|
+
// path in this file (switchAwayFrom()'s callers): false only when the
|
|
1308
|
+
// cleanup's own render failed — the (still pristine) block is left
|
|
1309
|
+
// as-is, on both `lines` and the stack, for a later attempt to retry.
|
|
1310
|
+
async function discardPristineInsert() {
|
|
1311
|
+
pristineInsert = null;
|
|
1312
|
+
const discarded = stack.discardTop(lines);
|
|
1313
|
+
if (!discarded) return true; // nothing to discard — defensive, should not happen
|
|
1314
|
+
const prevLines = lines;
|
|
1315
|
+
lines = discarded.lines;
|
|
1316
|
+
setDirty();
|
|
1317
|
+
const okRender = await safeRerenderAll();
|
|
1318
|
+
if (!okRender) {
|
|
1319
|
+
stack.push(discarded.op); // put it back — see discardTop()'s own comment
|
|
1320
|
+
lines = prevLines;
|
|
1321
|
+
setDirty();
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function focusInsertedBlock(newStartLine, kind) {
|
|
1328
|
+
const target = blocks.find((b) => b.startLine === newStartLine);
|
|
1329
|
+
if (!target) return;
|
|
1330
|
+
const blockEl = document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
1331
|
+
if (!blockEl) return;
|
|
1332
|
+
if (kind === 'code') {
|
|
1333
|
+
// Code blocks are never WYSIWYG-armed (armEditables() above has no
|
|
1334
|
+
// 'code' branch) — same degraded-block contract as any other fence:
|
|
1335
|
+
// click opens the raw in-place source editor directly. openRawEditor()
|
|
1336
|
+
// itself places the caret at the END of the textarea (its generic
|
|
1337
|
+
// contract, shared with every other raw-edit open path) — that lands
|
|
1338
|
+
// AFTER the closing fence, not on the blank BODY line
|
|
1339
|
+
// BLOCK_SKELETONS['code'] (see its own comment) puts there
|
|
1340
|
+
// specifically so typing has somewhere to land. Move it there once
|
|
1341
|
+
// the textarea exists.
|
|
1342
|
+
await openRawEditor(blockEl);
|
|
1343
|
+
const ta = blockEl.querySelector('textarea.ed-raw');
|
|
1344
|
+
if (ta) {
|
|
1345
|
+
const nlIdx = ta.value.indexOf('\n');
|
|
1346
|
+
const pos = nlIdx === -1 ? ta.value.length : nlIdx + 1;
|
|
1347
|
+
ta.setSelectionRange(pos, pos);
|
|
1348
|
+
ta.focus();
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (kind === 'table') {
|
|
1353
|
+
const tableEl = blockContentEl(blockEl);
|
|
1354
|
+
const firstBodyRow = tableEl ? bodyRowsOf(tableEl)[0] : null;
|
|
1355
|
+
const firstBodyCell = firstBodyRow ? firstBodyRow.cells[0] : null;
|
|
1356
|
+
if (firstBodyCell) focusAndSelectAll(firstBodyCell);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const editEl = blockContentEl(blockEl);
|
|
1360
|
+
if (editEl) focusAndSelectAll(editEl);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Inserts a new block of `kind` directly below `blockEl`. Requirement:
|
|
1364
|
+
// structural ops resolve any open burst FIRST (single-flight, same as
|
|
1365
|
+
// every other structural op in this file), then re-query the LIVE block
|
|
1366
|
+
// by data-block-id (the resolution may have committed a DIFFERENT block's
|
|
1367
|
+
// dirty burst, swapping the whole `.content` subtree and detaching
|
|
1368
|
+
// `blockEl` along with it — same "ensureTableBurstOpen()'s Finding 6"
|
|
1369
|
+
// recovery idiom used throughout this file), THEN acts.
|
|
1370
|
+
async function insertBlockBelow(blockEl, kind) {
|
|
1371
|
+
if (!blockEl) return;
|
|
1372
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1373
|
+
const ok = await switchAwayFrom();
|
|
1374
|
+
if (!ok) return;
|
|
1375
|
+
let liveBlockEl = blockEl;
|
|
1376
|
+
if (!document.body.contains(blockEl)) {
|
|
1377
|
+
liveBlockEl = document.querySelector('.ed-block[data-block-id="' + blockId + '"]');
|
|
1378
|
+
if (!liveBlockEl) return;
|
|
1379
|
+
}
|
|
1380
|
+
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
1381
|
+
const block = blocks.find((b) => b.id === liveBlockId);
|
|
1382
|
+
if (!block) return;
|
|
1383
|
+
const newLines = BLOCK_SKELETONS[kind];
|
|
1384
|
+
if (!newLines) return;
|
|
1385
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
|
|
1386
|
+
const prevLines = lines;
|
|
1387
|
+
lines = result.lines;
|
|
1388
|
+
const okRender = await safeRerenderAll();
|
|
1389
|
+
if (!okRender) {
|
|
1390
|
+
const rollback = stack.undo(lines);
|
|
1391
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
// §10-gap fix (review): mark the freshly-inserted block "pristine" —
|
|
1395
|
+
// see `pristineInsert`'s own comment for the full contract. `blocks`
|
|
1396
|
+
// was just reassigned by the successful rerenderAll() above, so this
|
|
1397
|
+
// is the server-authoritative id for the block at `newStartLine`.
|
|
1398
|
+
const target = blocks.find((b) => b.startLine === result.newStartLine);
|
|
1399
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
1400
|
+
await focusInsertedBlock(result.newStartLine, kind);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
|
|
1404
|
+
// — unchanged, see its own comment — to any block type, not just an
|
|
1405
|
+
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
1406
|
+
// precondition as insertBlockBelow() above.
|
|
1407
|
+
async function deleteBlockViaGutter(blockEl) {
|
|
1408
|
+
if (!blockEl) return;
|
|
1409
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1410
|
+
const ok = await switchAwayFrom();
|
|
1411
|
+
if (!ok) return;
|
|
1412
|
+
let liveBlockEl = blockEl;
|
|
1413
|
+
if (!document.body.contains(blockEl)) {
|
|
1414
|
+
liveBlockEl = document.querySelector('.ed-block[data-block-id="' + blockId + '"]');
|
|
1415
|
+
if (!liveBlockEl) return;
|
|
1416
|
+
}
|
|
1417
|
+
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
1418
|
+
const block = blocks.find((b) => b.id === liveBlockId);
|
|
1419
|
+
if (!block) return;
|
|
1420
|
+
const result = commitListBlockRemoval({ lines, blocks, stack }, liveBlockId);
|
|
1421
|
+
const prevLines = lines;
|
|
1422
|
+
lines = result.lines;
|
|
1423
|
+
const okRender = await safeRerenderAll();
|
|
1424
|
+
if (!okRender) {
|
|
1425
|
+
const rollback = stack.undo(lines);
|
|
1426
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// The ⠿ menu's "MD 原始碼" escape hatch: discards (never commits) any
|
|
1431
|
+
// in-progress burst on THIS block — same "throw away my WYSIWYG edits,
|
|
1432
|
+
// switch to raw-edit against the untouched on-disk source" contract the
|
|
1433
|
+
// old bar's MD button had — then opens the raw textarea. Deliberately
|
|
1434
|
+
// does NOT go through switchAwayFrom() for `blockEl`'s own burst (that
|
|
1435
|
+
// would COMMIT it, the opposite of what this button means); it still
|
|
1436
|
+
// resolves (commits/cancels) anything ELSE that might be open first, as a
|
|
1437
|
+
// defensive precondition, same as openRawEditor()'s own guard.
|
|
1438
|
+
async function openRawViaGutter(blockEl) {
|
|
1439
|
+
if (!blockEl) return;
|
|
1440
|
+
if (currentBurst && currentBurst.blockEl === blockEl) {
|
|
1441
|
+
// Final-review Finding 4 (Important): this used to null `currentBurst`
|
|
1442
|
+
// WITHOUT restoring `editEl.innerHTML` first — unlike every other
|
|
1443
|
+
// "discard this burst" exit (revertBurstAndEnd()/
|
|
1444
|
+
// revertTableBurstAndEnd() above both do `editEl.innerHTML =
|
|
1445
|
+
// burst.original` before nulling), so the burst's un-committed DOM
|
|
1446
|
+
// (whatever the user had typed) stayed sitting in the live DOM,
|
|
1447
|
+
// un-reverted. openRawEditor() below reads its raw-textarea seed from
|
|
1448
|
+
// the block's SOURCE (`lines`), not from this DOM, so the discarded
|
|
1449
|
+
// typing didn't show up in the raw editor itself — but the
|
|
1450
|
+
// now-orphaned WYSIWYG surface behind it still held it. The very next
|
|
1451
|
+
// focus/blur cycle on that same block (click into it, click back out
|
|
1452
|
+
// — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
|
|
1453
|
+
// and committed it as if it were live content: the "discarded" edit
|
|
1454
|
+
// resurrected itself into `lines`. Restore the pre-edit snapshot
|
|
1455
|
+
// FIRST, mirroring revertBurstAndEnd()'s own contract, so nothing is
|
|
1456
|
+
// left behind for a later burst to accidentally pick up and commit.
|
|
1457
|
+
currentBurst.editEl.innerHTML = currentBurst.original;
|
|
1458
|
+
currentBurst.history.dispose();
|
|
1459
|
+
currentBurst = null;
|
|
1460
|
+
resetSelToolbarState();
|
|
1461
|
+
} else {
|
|
1462
|
+
const ok = await switchAwayFrom();
|
|
1463
|
+
if (!ok) return;
|
|
1464
|
+
}
|
|
1465
|
+
openRawEditor(blockEl);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Starts a burst on `editEl` (a `.ed-wys-armed` content element) — called
|
|
1469
|
+
// from the delegated `focusin` listener below. captureFn snapshots the
|
|
1470
|
+
// surface's innerHTML; history.start() records snapshot 0 (the pre-edit
|
|
1471
|
+
// baseline Esc reverts to).
|
|
1472
|
+
function startBurst(editEl) {
|
|
1473
|
+
const blockEl = editEl.closest('.ed-block');
|
|
1474
|
+
if (!blockEl) return;
|
|
1475
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1476
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
1477
|
+
if (!block) return;
|
|
1478
|
+
const blockType = blockEl.getAttribute('data-block-type');
|
|
1479
|
+
const history = historyLib.createBurstHistory(() => editEl.innerHTML, { debounceMs: 400 });
|
|
1480
|
+
history.start();
|
|
1481
|
+
currentBurst = {
|
|
1482
|
+
blockEl, editEl, blockId, blockType,
|
|
1483
|
+
depth: blockDepthOf(blockType, editEl),
|
|
1484
|
+
original: editEl.innerHTML,
|
|
1485
|
+
history,
|
|
1486
|
+
};
|
|
1487
|
+
selToolbarEditEl = editEl;
|
|
1488
|
+
if (!selToolbarListener) {
|
|
1489
|
+
selToolbarListener = onSelectionChangeForToolbar;
|
|
1490
|
+
document.addEventListener('selectionchange', onSelectionChangeForToolbar);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Extracts the three-statement burst teardown (dispose history, null
|
|
1495
|
+
// currentBurst, reset toolbar) into a named helper so every refuse/no-op
|
|
1496
|
+
// path shares the same idiom and structural ops can call it explicitly
|
|
1497
|
+
// after their own commit to prevent the subsequent focusout from
|
|
1498
|
+
// double-committing (the focusin/focusout handlers check currentBurst).
|
|
1499
|
+
function endBurstWithoutResolve() {
|
|
1500
|
+
const burst = currentBurst;
|
|
1501
|
+
if (burst) burst.history.dispose();
|
|
1502
|
+
currentBurst = null;
|
|
1503
|
+
resetSelToolbarState();
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Resolves the currently-open burst: serialize -> commit if changed (via
|
|
1507
|
+
// the same commitEdit()/safeRerenderAll() pipeline every other edit in
|
|
1508
|
+
// this file uses), silently drop if unchanged. Same true/false contract as
|
|
1509
|
+
// activeEditor.commitNow() above (false = a network commit genuinely
|
|
1510
|
+
// failed; the burst stays OPEN with its DOM untouched, banner already
|
|
1511
|
+
// shown — Global Constraint's commit-failure rollback + single-flight
|
|
1512
|
+
// semantics). Called only from resolveOpenSession() (switchAwayFrom()'s
|
|
1513
|
+
// extended body) — never call this directly.
|
|
1514
|
+
async function resolveBurst() {
|
|
1515
|
+
const burst = currentBurst;
|
|
1516
|
+
// Task 5: any burst resolution (table or not) invalidates whatever
|
|
1517
|
+
// boundary the hover-insert overlay was tracking — cheap/idempotent even
|
|
1518
|
+
// when the bubbles are already hidden, so unconditional here is simpler
|
|
1519
|
+
// than gating it on burst.blockType === 'table'.
|
|
1520
|
+
hideTableInsertBubbles();
|
|
1521
|
+
// Task 6: same reasoning for the grip handles, the edge-click menu, and
|
|
1522
|
+
// any in-flight row drag — all three reference elements of whichever
|
|
1523
|
+
// table this resolution is about to commit/detach.
|
|
1524
|
+
hideTableGrips();
|
|
1525
|
+
hideTableEdgeMenu();
|
|
1526
|
+
cancelTeDrag();
|
|
1527
|
+
if (!burst) return true;
|
|
1528
|
+
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
1529
|
+
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
1530
|
+
// OWN first burst resolves — one way (a real edit, below) or the
|
|
1531
|
+
// other (still byte-identical to the skeleton, checked next).
|
|
1532
|
+
// Captured + cleared BEFORE that check runs so no later resolution on
|
|
1533
|
+
// this same block can ever act on a stale reference once it's had a
|
|
1534
|
+
// real edit.
|
|
1535
|
+
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
1536
|
+
if (wasPristineForThisBlock) pristineInsert = null;
|
|
1537
|
+
// Final-review Finding 2 (Critical): a zero-edit burst (focus into a
|
|
1538
|
+
// surface, then blur/click-out with no actual DOM mutation — e.g.
|
|
1539
|
+
// clicking into a hand-padded/long-dash table cell, or just clicking a
|
|
1540
|
+
// paragraph and clicking away) used to fall straight through to the
|
|
1541
|
+
// serialize -> commitEdit() path below regardless. table-md.js's/
|
|
1542
|
+
// inline-md.js's/list-md.js's serializers always emit their OWN
|
|
1543
|
+
// canonical form (single space between pipes, minimal `---`
|
|
1544
|
+
// separators, no padding) — so opening a burst on non-canonical-but-
|
|
1545
|
+
// otherwise-untouched source (hundreds of hand-formatted tables exist
|
|
1546
|
+
// in real corpora) silently REWROTE it to the serializer's minimal form
|
|
1547
|
+
// and marked the document dirty even though the user typed nothing.
|
|
1548
|
+
// `burst.original` is exactly `burst.editEl.innerHTML` captured at
|
|
1549
|
+
// focus time (startBurst()/startTableBurst() above, for every block
|
|
1550
|
+
// type this burst substrate covers — paragraph/heading/list/table all
|
|
1551
|
+
// store it the same way) — a byte-identical innerHTML means the DOM
|
|
1552
|
+
// genuinely never changed, so drop the burst here exactly like the
|
|
1553
|
+
// `commitResult.op === null` no-op path below, without ever reaching
|
|
1554
|
+
// the serializer (and therefore without ever risking a canonicalizing
|
|
1555
|
+
// rewrite of untouched content).
|
|
1556
|
+
if (burst.editEl.innerHTML === burst.original) {
|
|
1557
|
+
endBurstWithoutResolve();
|
|
1558
|
+
// §10-gap fix (review): untouched AND was pristine — an ordinary
|
|
1559
|
+
// "insert +, click away without typing" changed-my-mind. Auto-remove
|
|
1560
|
+
// the block instead of leaving its skeleton on disk.
|
|
1561
|
+
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
burst.history.flushTyping();
|
|
1565
|
+
// Task 7 (Phase 4): li burst — serialize the whole list run through
|
|
1566
|
+
// serializeList(), commit via commitRangeEdit() over the full run range.
|
|
1567
|
+
// Per-li degrade (spec §8): if OTHER lis in the run are unsupported,
|
|
1568
|
+
// commit only the edited li's own line range to avoid lossy round-trip
|
|
1569
|
+
// of their content (serializeList strips unsupported inline elements from
|
|
1570
|
+
// `md`, so whole-run commit would silently delete their content).
|
|
1571
|
+
if (burst.blockType === 'li') {
|
|
1572
|
+
const root = listRunRootOf(burst.editEl);
|
|
1573
|
+
if (!root) { endBurstWithoutResolve(); return true; }
|
|
1574
|
+
const { md: runMd, unsupported, unsupportedByLi } = listMd.serializeList(root);
|
|
1575
|
+
// Refuse if the EDITED li itself has unsupported inline content.
|
|
1576
|
+
// RULING F-O: do NOT call openRawEditor() on a <li> element — injecting
|
|
1577
|
+
// a textarea into list structure corrupts list-md serialization and
|
|
1578
|
+
// renders badly. Show banner + teardown + rerenderAll (file is untouched,
|
|
1579
|
+
// burst never wrote to `lines`) + return false.
|
|
1580
|
+
const editedIdStr = String(burst.blockId);
|
|
1581
|
+
if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
|
|
1582
|
+
showBanner('含不支援的格式,改用原始碼編輯', null, null);
|
|
1583
|
+
endBurstWithoutResolve();
|
|
1584
|
+
await safeRerenderAll();
|
|
1585
|
+
return false;
|
|
1586
|
+
}
|
|
1587
|
+
const range = runRangeOf({ lines, blocks, stack }, root);
|
|
1588
|
+
if (!range) { endBurstWithoutResolve(); return true; }
|
|
1589
|
+
let commitMd, commitStart, commitEnd;
|
|
1590
|
+
if (unsupported.length > 0) {
|
|
1591
|
+
// The run contains SOME unsupported content — commit only the edited
|
|
1592
|
+
// li's own line range so nothing else in the run is round-tripped
|
|
1593
|
+
// through the tight (blank-line-collapsed) runMd.
|
|
1594
|
+
//
|
|
1595
|
+
// F-W (silent data loss): the gate MUST key on `unsupported`
|
|
1596
|
+
// (the SUPERSET), not `unsupportedByLi`. `unsupportedByLi` collects
|
|
1597
|
+
// ONLY per-li inline-serializer names (e.g. VIDEO); `unsupported`
|
|
1598
|
+
// additionally gets 'P' pushed for every LOOSE list item plus stray
|
|
1599
|
+
// TEXT / foreign non-LI elements. A run containing a LOOSE li pushes
|
|
1600
|
+
// 'P' to `unsupported` ONLY — so keying on `unsupportedByLi` took the
|
|
1601
|
+
// whole-run tight commit and DELETED the loose blank line, silently
|
|
1602
|
+
// flattening the nested sublist. Keying on `unsupported` closes the
|
|
1603
|
+
// loose-'P', stray-TEXT and foreign-element cases in one shot.
|
|
1604
|
+
const editedBlock = blocks.find((b) => b.id === burst.blockId);
|
|
1605
|
+
if (!editedBlock) { endBurstWithoutResolve(); return true; }
|
|
1606
|
+
// F-W (the trap): the slice offset MUST be the edited li's POSITION
|
|
1607
|
+
// among the run's li blocks in DFS document order, NOT the source-line
|
|
1608
|
+
// delta (editedBlock.startLine - range.startLine). The tight runMd
|
|
1609
|
+
// emits exactly ONE line per li in DFS order (list-md.js pushes one
|
|
1610
|
+
// line per item) and has NO blank lines, so a loose item present
|
|
1611
|
+
// anywhere earlier in the run makes a later supported li's SOURCE
|
|
1612
|
+
// startLine overshoot the tight runMd's line count — the old delta
|
|
1613
|
+
// slice then returned '' and commitRangeRemoval DELETED the li's line.
|
|
1614
|
+
// Indexing by li position is blank-line-robust: the k-th `.ed-block`
|
|
1615
|
+
// li returned by querySelectorAll (pre-order DFS) is the k-th line of
|
|
1616
|
+
// runMd (serializeList emits in the same pre-order DFS), so
|
|
1617
|
+
// runLines[offset] is exactly THIS li's serialized line, independent
|
|
1618
|
+
// of any loose blank lines in the source.
|
|
1619
|
+
const runLiIds = Array.prototype.slice
|
|
1620
|
+
.call(root.querySelectorAll('.ed-block'))
|
|
1621
|
+
.map((el) => Number(el.getAttribute('data-block-id')));
|
|
1622
|
+
const offset = runLiIds.indexOf(burst.blockId);
|
|
1623
|
+
if (offset < 0) { endBurstWithoutResolve(); return true; }
|
|
1624
|
+
commitMd = runMd.split('\n')[offset];
|
|
1625
|
+
commitStart = editedBlock.startLine;
|
|
1626
|
+
commitEnd = editedBlock.endLine;
|
|
1627
|
+
} else {
|
|
1628
|
+
// Fully-supported run: commit the whole range at once.
|
|
1629
|
+
commitMd = runMd;
|
|
1630
|
+
commitStart = range.startLine;
|
|
1631
|
+
commitEnd = range.endLine;
|
|
1632
|
+
}
|
|
1633
|
+
const liCommitResult = (commitMd === '')
|
|
1634
|
+
? commitRangeRemoval({ lines, blocks, stack }, commitStart, commitEnd)
|
|
1635
|
+
: commitRangeEdit({ lines, blocks, stack }, commitStart, commitEnd, commitMd);
|
|
1636
|
+
if (liCommitResult.op === null) {
|
|
1637
|
+
endBurstWithoutResolve();
|
|
1638
|
+
return true;
|
|
1639
|
+
}
|
|
1640
|
+
const liPrevLines = lines;
|
|
1641
|
+
lines = liCommitResult.lines;
|
|
1642
|
+
const liOk = await safeRerenderAll();
|
|
1643
|
+
if (!liOk) {
|
|
1644
|
+
const liRollback = stack.undo(lines);
|
|
1645
|
+
lines = liRollback ? liRollback.lines : liPrevLines;
|
|
1646
|
+
return false;
|
|
1647
|
+
}
|
|
1648
|
+
return true;
|
|
1649
|
+
}
|
|
1650
|
+
// Task 4 (Phase 3): a list burst serializes through list-md.js's
|
|
1651
|
+
// serializeList() (it takes the list ROOT element, exactly what
|
|
1652
|
+
// burst.editEl already is for a 'list' burst — see armEditables() above)
|
|
1653
|
+
// instead of inline-md.js's serializeInline(); Task 5: a table burst
|
|
1654
|
+
// serializes through table-md.js's serializeTable() the same way (it
|
|
1655
|
+
// takes the TABLE element, exactly what burst.editEl already is for a
|
|
1656
|
+
// 'table' burst). Every other block type (paragraph/heading) keeps using
|
|
1657
|
+
// serializeInline() unchanged.
|
|
1658
|
+
// LEGACY (pre-per-li): the 'list' branch below is unreachable in the
|
|
1659
|
+
// per-li architecture — blockmap no longer emits type:'list' blocks, so
|
|
1660
|
+
// no startBurst() call can produce blockType === 'list'. Kept until the
|
|
1661
|
+
// whole 'list' surface is removed in a later cleanup task.
|
|
1662
|
+
const result = burst.blockType === 'list' ? listMd.serializeList(burst.editEl)
|
|
1663
|
+
: burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
|
|
1664
|
+
: inlineMd.serializeInline(burst.editEl);
|
|
1665
|
+
if (result.unsupported.length > 0) {
|
|
1666
|
+
// Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
|
|
1667
|
+
// commit()): our own paste handler only ever inserts plain text, but a
|
|
1668
|
+
// browser-native rich-paste/drag-drop could still land unsupported
|
|
1669
|
+
// markup mid-burst. Drop it, fall back to raw-edit prefilled with the
|
|
1670
|
+
// block's UNTOUCHED original source (this burst never wrote to
|
|
1671
|
+
// `lines`), and return false so the caller (switchAwayFrom(), on
|
|
1672
|
+
// behalf of whatever triggered this resolution) aborts instead of
|
|
1673
|
+
// proceeding as if the burst resolved cleanly.
|
|
1674
|
+
showBanner('含不支援的格式,改用原始碼編輯', null, null);
|
|
1675
|
+
endBurstWithoutResolve();
|
|
1676
|
+
openRawEditor(burst.blockEl);
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
// Final-review Finding 5 (carried over): an emptied-out heading must not
|
|
1680
|
+
// commit '#'.repeat(depth) + ' ' with nothing after the space. A list
|
|
1681
|
+
// burst's `depth` is always null (blockDepthOf() only computes it for
|
|
1682
|
+
// 'heading'), so it takes the plain result.md branch, same as a
|
|
1683
|
+
// paragraph.
|
|
1684
|
+
const newText = burst.depth === null ? result.md :
|
|
1685
|
+
(result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
|
|
1686
|
+
// Task 4 fix (review, Important): a list burst that serialized to ''
|
|
1687
|
+
// means every item was removed (each <li> always emits a non-empty
|
|
1688
|
+
// marker line — see list-md.js — so a 0-line result can ONLY happen
|
|
1689
|
+
// with 0 <li>s left) — delete the block's line range entirely instead
|
|
1690
|
+
// of committing a single stray blank line. See commitListBlockRemoval()'s
|
|
1691
|
+
// own comment for the exact byte-level contract.
|
|
1692
|
+
// LEGACY (pre-per-li): the 'list' branch below is unreachable in the
|
|
1693
|
+
// per-li architecture — kept until the whole 'list' surface is removed.
|
|
1694
|
+
const commitResult = (burst.blockType === 'list' && result.md === '')
|
|
1695
|
+
? commitListBlockRemoval({ lines, blocks, stack }, burst.blockId)
|
|
1696
|
+
: commitEdit({ lines, blocks, stack }, burst.blockId, newText);
|
|
1697
|
+
if (commitResult.op === null) {
|
|
1698
|
+
endBurstWithoutResolve();
|
|
1699
|
+
return true;
|
|
1700
|
+
}
|
|
1701
|
+
const prevLines = lines;
|
|
1702
|
+
lines = commitResult.lines;
|
|
1703
|
+
const ok = await safeRerenderAll();
|
|
1704
|
+
if (!ok) {
|
|
1705
|
+
const rollback = stack.undo(lines);
|
|
1706
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
1707
|
+
// Burst stays open: DOM/history untouched, banner already shown by
|
|
1708
|
+
// safeRerenderAll(). rerenderAll() never ran its belt-and-braces
|
|
1709
|
+
// `currentBurst = null` reset on this failure path (that reset only
|
|
1710
|
+
// fires on an actual successful swap), so `currentBurst` still points
|
|
1711
|
+
// at the same (still live, still armed) surface here.
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
// Success: rerenderAll() already replaced the whole .content subtree
|
|
1715
|
+
// (this block included), re-armed it via armEditables(), and — belt and
|
|
1716
|
+
// braces, same idiom as activeEditor/resetSelToolbarState() elsewhere in
|
|
1717
|
+
// this file — unconditionally nulled `currentBurst` and disposed its
|
|
1718
|
+
// history. Nothing left to do here.
|
|
1719
|
+
return true;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Live `.ed-block` element whose block STARTS at `startLine` in the current
|
|
1723
|
+
// (post-render) `blocks` array, or null. Every server render re-derives
|
|
1724
|
+
// block ids from the markdown, so a startLine captured BEFORE a commit is
|
|
1725
|
+
// the only stable way back to a specific block afterwards — the same lookup
|
|
1726
|
+
// focusBlockAtLine() below does, exposed separately for the structural ops
|
|
1727
|
+
// (Task 8's empty-li → paragraph conversion) that need the ELEMENT rather
|
|
1728
|
+
// than the caret.
|
|
1729
|
+
function blockElAtLine(startLine) {
|
|
1730
|
+
const target = blocks.find((b) => b.startLine === startLine);
|
|
1731
|
+
if (!target) return null;
|
|
1732
|
+
return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Finds the block whose startLine === `startLine` in the current `blocks`
|
|
1736
|
+
// array and focuses its WYSIWYG surface. `caretToEnd` = true places the
|
|
1737
|
+
// caret after the last character; false (default) places it at the start.
|
|
1738
|
+
// Best-effort: silently no-ops when the block or its surface cannot be
|
|
1739
|
+
// found (unarmed li, raw-edit block). Used by structural ops in Tasks 8-9
|
|
1740
|
+
// to restore focus after rerenderAll().
|
|
1741
|
+
function focusBlockAtLine(startLine, caretToEnd) {
|
|
1742
|
+
const target = blocks.find((b) => b.startLine === startLine);
|
|
1743
|
+
if (!target) return;
|
|
1744
|
+
const blockEl = document.querySelector('[data-block-id="' + target.id + '"]');
|
|
1745
|
+
if (!blockEl) return;
|
|
1746
|
+
const surface = blockContentEl(blockEl);
|
|
1747
|
+
if (!surface) return;
|
|
1748
|
+
surface.focus();
|
|
1749
|
+
try {
|
|
1750
|
+
const range = document.createRange();
|
|
1751
|
+
range.selectNodeContents(surface);
|
|
1752
|
+
range.collapse(!caretToEnd); // true = to start; false = to end
|
|
1753
|
+
const sel = window.getSelection();
|
|
1754
|
+
sel.removeAllRanges();
|
|
1755
|
+
sel.addRange(range);
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
// best-effort caret placement — ignore on empty or non-text surfaces
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// ── Task 8 (Phase 4): structural commit for a per-li block run ──────────
|
|
1762
|
+
// Spec §3: a single <li> cannot emit its own line (ordinals and ancestor
|
|
1763
|
+
// marker widths are tree-global), so the commit unit for ANY structural
|
|
1764
|
+
// change is the contiguous list RUN — re-serialize the whole run, replace
|
|
1765
|
+
// its line range once. That keeps every structural key at exactly ONE undo
|
|
1766
|
+
// op (a single contiguous range op on the existing UndoStack).
|
|
1767
|
+
//
|
|
1768
|
+
// RULING F-F: module scope, deliberately NOT nested inside the keydown
|
|
1769
|
+
// handler — Task 9's delegated checkbox-toggle click handler calls this too.
|
|
1770
|
+
//
|
|
1771
|
+
// The DOM mutation must already have happened when this is called; it reads
|
|
1772
|
+
// the live run back out through listMd.serializeList(). `focusStartLine` is
|
|
1773
|
+
// the (post-commit) line the caret should end up on — see
|
|
1774
|
+
// runLineOfListItem() below for how a caller computes it — or null to leave
|
|
1775
|
+
// focus wherever the re-render puts it. Returns true on success, false when
|
|
1776
|
+
// the commit's own re-render failed (rolled back the same way every other
|
|
1777
|
+
// commit path in this file does: stack.undo() + restore `lines`).
|
|
1778
|
+
//
|
|
1779
|
+
// `runEl` is normally the `.ed-li-text` surface the key came from, but any
|
|
1780
|
+
// node still inside the run works (listRunRootOf() walks up from it, and the
|
|
1781
|
+
// run ROOT itself resolves to itself). A caller whose mutation DETACHES that
|
|
1782
|
+
// surface — empty-Enter's li removal — must pass the run root instead, or
|
|
1783
|
+
// this cannot find the run at all.
|
|
1784
|
+
// Both "cannot locate the run" refusals below re-render before returning: the
|
|
1785
|
+
// caller's DOM mutation has ALREADY happened by the time this function runs,
|
|
1786
|
+
// so bailing out without a render would leave the screen showing a structural
|
|
1787
|
+
// change that never reached `lines`, with no burst left tracking it. Same
|
|
1788
|
+
// reasoning (and same remedy) as the `op === null` path further down.
|
|
1789
|
+
//
|
|
1790
|
+
// CALLER GATE CONTRACT: every caller MUST check
|
|
1791
|
+
// listRunSupportsStructuralEdit(root) and call refuseStructuralListEdit()
|
|
1792
|
+
// BEFORE mutating the DOM and calling this function. This function
|
|
1793
|
+
// re-serializes the WHOLE run — an unsupported li anywhere in it would have
|
|
1794
|
+
// its content silently deleted if the gate is skipped. The keydown handlers
|
|
1795
|
+
// (Tab, Enter) and the Task 9 checkbox click handler both enforce this.
|
|
1796
|
+
async function commitListStructure(runEl, focusStartLine, caretToEnd, presetRange) {
|
|
1797
|
+
const root = listRunRootOf(runEl);
|
|
1798
|
+
if (!root) { endBurstWithoutResolve(); await safeRerenderAll(); return false; }
|
|
1799
|
+
const { md } = listMd.serializeList(root);
|
|
1800
|
+
// The run's line range is read back off its own li blocks' ids — which
|
|
1801
|
+
// requires at least one li to still BE there. A caller whose mutation
|
|
1802
|
+
// removed the run's last item therefore captures the range BEFORE mutating
|
|
1803
|
+
// and passes it in; everyone else lets it be derived here.
|
|
1804
|
+
const range = presetRange || runRangeOf({ lines, blocks, stack }, root);
|
|
1805
|
+
if (!range) { endBurstWithoutResolve(); await safeRerenderAll(); return false; }
|
|
1806
|
+
const result = (md === '')
|
|
1807
|
+
// Every <li> emits a non-empty marker line, so md === '' can only mean
|
|
1808
|
+
// the run has no items left — delete the range outright (absorbing one
|
|
1809
|
+
// adjacent blank separator) instead of committing a stray blank line.
|
|
1810
|
+
// Same contract commitListBlockRemoval() documents.
|
|
1811
|
+
? commitRangeRemoval({ lines, blocks, stack }, range.startLine, range.endLine)
|
|
1812
|
+
: commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
|
|
1813
|
+
// Structural ops bypass the burst's own resolve: the commit above already
|
|
1814
|
+
// wrote the run, so the focusout that follows this key must NOT re-commit
|
|
1815
|
+
// the (now stale) surface a second time.
|
|
1816
|
+
endBurstWithoutResolve();
|
|
1817
|
+
const prevLines = lines;
|
|
1818
|
+
// op === null means the mutated DOM re-serialized byte-identically (e.g. a
|
|
1819
|
+
// no-op reorder). `lines` is untouched, but the local DOM mutation is
|
|
1820
|
+
// still sitting there un-committed — re-render anyway so what's on screen
|
|
1821
|
+
// is always exactly what's in `lines`.
|
|
1822
|
+
if (result.op !== null) lines = result.lines;
|
|
1823
|
+
const ok = await safeRerenderAll();
|
|
1824
|
+
if (!ok) {
|
|
1825
|
+
if (result.op !== null) {
|
|
1826
|
+
const rollback = stack.undo(lines);
|
|
1827
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
1828
|
+
}
|
|
1829
|
+
// Deliberately NOT a second safeRerenderAll(), unlike the two refusals
|
|
1830
|
+
// above: this shape is different — a render WAS attempted and failed, so
|
|
1831
|
+
// rerenderAll() left `.content` untouched by contract and already showed
|
|
1832
|
+
// the "your edit was not applied" banner. Retrying immediately would only
|
|
1833
|
+
// stack an identical second banner. `lines` is authoritative and correct;
|
|
1834
|
+
// the screen keeps the local structural mutation until the next
|
|
1835
|
+
// successful render (any later commit, undo, or redo) re-derives the DOM
|
|
1836
|
+
// from `lines`. Same convention as insertBlockBelow() /
|
|
1837
|
+
// deleteBlockViaGutter() / resolveBurst()'s own failure paths.
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
if (focusStartLine != null) focusBlockAtLine(focusStartLine, caretToEnd);
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// The line `targetLi` will occupy once the run it belongs to is committed by
|
|
1845
|
+
// commitListStructure() above. list-md.js's one-li==one-line write invariant
|
|
1846
|
+
// means the run's serialized markdown has exactly one line per <li> in
|
|
1847
|
+
// document order (its emission walk — item line, then that item's nested
|
|
1848
|
+
// lists, then the next sibling — is a pre-order DFS, i.e. exactly the order
|
|
1849
|
+
// querySelectorAll('li') returns), so the target's line is the run's own
|
|
1850
|
+
// startLine plus its index in that walk. Holds even when the PRE-commit
|
|
1851
|
+
// source had multi-line items, because the commit replaces the whole range
|
|
1852
|
+
// with the canonical one-line-per-item form.
|
|
1853
|
+
// Returns null when the run (or the item) cannot be located.
|
|
1854
|
+
function runLineOfListItem(rootEl, targetLi) {
|
|
1855
|
+
const range = runRangeOf({ lines, blocks, stack }, rootEl);
|
|
1856
|
+
if (!range) return null;
|
|
1857
|
+
const lis = Array.prototype.slice.call(rootEl.querySelectorAll('li'));
|
|
1858
|
+
const idx = lis.indexOf(targetLi);
|
|
1859
|
+
if (idx === -1) return null;
|
|
1860
|
+
return range.startLine + idx;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// Degrade-never-lose gate for structural keys: refuse the key outright when
|
|
1864
|
+
// ANY li in the run is unsupported (loose <p>-wrapped item, foreign element,
|
|
1865
|
+
// stray text directly under the UL/OL, unsupported inline markup).
|
|
1866
|
+
// serializeList() strips what it cannot represent from `md`, so committing
|
|
1867
|
+
// such a run deletes that content silently.
|
|
1868
|
+
//
|
|
1869
|
+
// RULING F-R — why the gate is RUN-WIDE, and why that does not contradict
|
|
1870
|
+
// spec §8's per-li narrowing. §8 governs which li you may TYPE in: a text
|
|
1871
|
+
// edit can be confined to the edited li's OWN line range (Task 7's partial-run
|
|
1872
|
+
// path in resolveBurst() above), leaving every other li's source bytes
|
|
1873
|
+
// untouched, so one unsupported li only degrades itself. That narrowing is
|
|
1874
|
+
// impossible for a STRUCTURAL op: per spec §3 the commit unit IS the
|
|
1875
|
+
// contiguous run, because an indent / outdent / split rewrites OTHER lines'
|
|
1876
|
+
// indent prefixes and ordinals — so the whole run must be re-serialized, and
|
|
1877
|
+
// that re-serialization is exactly what emits garbage for an unsupported li
|
|
1878
|
+
// anywhere in it. Refusing run-wide is therefore the only non-corrupting
|
|
1879
|
+
// answer, not an over-broad one.
|
|
1880
|
+
//
|
|
1881
|
+
// Called BEFORE any mutation, so a refusal costs nothing to undo.
|
|
1882
|
+
function listRunSupportsStructuralEdit(rootEl) {
|
|
1883
|
+
return !!rootEl && listMd.serializeList(rootEl).unsupported.length === 0;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
|
|
1887
|
+
// end the burst WITHOUT committing — replaces the old per-session Esc
|
|
1888
|
+
// cancel. Clears `currentBurst` BEFORE calling blur() so the delegated
|
|
1889
|
+
// focusout handler (which fires synchronously from blur()) finds nothing
|
|
1890
|
+
// left to resolve and no-ops, instead of re-entering resolveBurst().
|
|
1891
|
+
async function revertBurstAndEnd(editEl) {
|
|
1892
|
+
const burst = currentBurst;
|
|
1893
|
+
if (!burst || burst.editEl !== editEl) return;
|
|
1894
|
+
// §10-gap fix (review): Escape ALWAYS reverts to `burst.original` — for
|
|
1895
|
+
// a pristine block that's exactly its still-untouched skeleton, so
|
|
1896
|
+
// this unconditionally qualifies as "abandoned" (no separate
|
|
1897
|
+
// unchanged-check needed here, unlike resolveBurst()'s branch, where a
|
|
1898
|
+
// real commit is also possible).
|
|
1899
|
+
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
1900
|
+
if (wasPristineForThisBlock) pristineInsert = null;
|
|
1901
|
+
burst.history.dispose();
|
|
1902
|
+
editEl.innerHTML = burst.original;
|
|
1903
|
+
currentBurst = null;
|
|
1904
|
+
resetSelToolbarState();
|
|
1905
|
+
if (wasPristineForThisBlock) {
|
|
1906
|
+
await discardPristineInsert(); // rerenderAll() already detaches editEl — nothing left to blur()
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
editEl.blur();
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Ctrl+Z inside a burst: step the burst-local history first; only once
|
|
1913
|
+
// it's exhausted (atBottom — the surface is already back to its pre-focus
|
|
1914
|
+
// baseline) does this cascade OUT to the document-level undo() stack,
|
|
1915
|
+
// after committing the (by definition unchanged, so a no-op) burst first
|
|
1916
|
+
// — see switchAwayFrom()/resolveBurst() above. Fire-and-forget: the
|
|
1917
|
+
// keydown handler already called preventDefault() synchronously.
|
|
1918
|
+
function burstUndo(editEl) {
|
|
1919
|
+
const burst = currentBurst;
|
|
1920
|
+
if (!burst || burst.editEl !== editEl) return;
|
|
1921
|
+
const state = burst.history.undo();
|
|
1922
|
+
if (state !== null) {
|
|
1923
|
+
editEl.innerHTML = state;
|
|
1924
|
+
placeCaretAtEnd(editEl); // best-effort — see the file-level caret-quirk note
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
// §10-gap fix (review): if this burst is an untouched pristine insert,
|
|
1928
|
+
// switchAwayFrom() below will itself auto-remove the block (resolveBurst()'s
|
|
1929
|
+
// pristineInsert branch) — that auto-remove IS the undo the user just
|
|
1930
|
+
// asked for (Ctrl+Z on a block with nothing else to step back through
|
|
1931
|
+
// locally). Chaining a SECOND undo() after it would incorrectly cascade
|
|
1932
|
+
// to whatever op preceded the insert instead. Computed HERE,
|
|
1933
|
+
// synchronously, off the same `burst` object switchAwayFrom() is about
|
|
1934
|
+
// to resolve — nothing can change either condition between this check
|
|
1935
|
+
// and that resolution running.
|
|
1936
|
+
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
1937
|
+
burst.editEl.innerHTML === burst.original);
|
|
1938
|
+
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Ctrl+Y / Ctrl+Shift+Z inside a burst: symmetric to burstUndo() above.
|
|
1942
|
+
function burstRedo(editEl) {
|
|
1943
|
+
const burst = currentBurst;
|
|
1944
|
+
if (!burst || burst.editEl !== editEl) return;
|
|
1945
|
+
const state = burst.history.redo();
|
|
1946
|
+
if (state !== null) {
|
|
1947
|
+
editEl.innerHTML = state;
|
|
1948
|
+
placeCaretAtEnd(editEl);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
switchAwayFrom().then((ok) => { if (ok) redo(); });
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// Records a programmatic (non-typing) mutation of `root` — a toolbar mark
|
|
1955
|
+
// toggle, a Shift+Enter <br> insertion, a paste — as its own burst-history
|
|
1956
|
+
// snapshot, per the brief ("Programmatic mutations ... call snap() after
|
|
1957
|
+
// applying"). A no-op when `root` isn't part of the currently-focused
|
|
1958
|
+
// burst's edit surface.
|
|
1959
|
+
//
|
|
1960
|
+
// Final-review Finding 3 (Important): a plain `editEl === root` equality
|
|
1961
|
+
// check misses every Task 5 table-cell caller. A table burst's
|
|
1962
|
+
// `currentBurst.editEl` is the WHOLE <table> (see startTableBurst() above
|
|
1963
|
+
// — one burst spans every cell), but the selection toolbar's mark-toggle
|
|
1964
|
+
// callers (applyMarkToggle()/applyLinkToggle() below) and the table-cell
|
|
1965
|
+
// paste handler all pass `root = selToolbarEditEl`/`cellEl.closest('table')`
|
|
1966
|
+
// — for a mark toggle specifically that's the individual CELL
|
|
1967
|
+
// (startTableBurst()/handleTableCellFocusIn() set `selToolbarEditEl =
|
|
1968
|
+
// cellEl`, never the table), which never strictly equals `editEl` even
|
|
1969
|
+
// though it's the burst's own content. `.contains()` catches that case
|
|
1970
|
+
// (and is a no-op broadening everywhere else: for paragraph/heading/list,
|
|
1971
|
+
// `root` IS `editEl`, so the first branch already matched and the
|
|
1972
|
+
// `.contains()` call never even runs). Without this, a bold/italic/link/
|
|
1973
|
+
// paste toggle inside a table cell silently skipped its own burst-history
|
|
1974
|
+
// snapshot — Ctrl+Z after it would step PAST the mark (or straight to
|
|
1975
|
+
// cascading out of the burst) instead of reverting just that toggle.
|
|
1976
|
+
function snapBurstIfActive(root, reason) {
|
|
1977
|
+
if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
|
|
1978
|
+
currentBurst.history.snap(reason);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// The delegated keydown handler's per-keystroke logic for a focused
|
|
1983
|
+
// `.ed-wys-armed` surface — Enter commits (via blur(), which the
|
|
1984
|
+
// delegated focusout handler turns into a resolveBurst() call — see
|
|
1985
|
+
// wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
|
|
1986
|
+
// it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
|
|
1987
|
+
function handleBurstKeydown(e, editEl) {
|
|
1988
|
+
if (!currentBurst || currentBurst.editEl !== editEl) return;
|
|
1989
|
+
// Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
|
|
1990
|
+
// are owned by handleLiKeydown() below (spec §4's key semantics for li
|
|
1991
|
+
// surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
|
|
1992
|
+
// Ctrl+Z, Ctrl+Y) falls through to the shared branches below and behaves
|
|
1993
|
+
// exactly as it does for a paragraph.
|
|
1994
|
+
if (currentBurst.blockType === 'li') {
|
|
1995
|
+
if (handleLiKeydown(e, editEl)) return;
|
|
1996
|
+
}
|
|
1997
|
+
// Task 4 (Phase 3): a list burst's Enter/Tab/Shift+Tab semantics are
|
|
1998
|
+
// materially different from paragraph/heading (split/indent/outdent
|
|
1999
|
+
// instead of commit/br) — handleListKeydown() owns that entire surface
|
|
2000
|
+
// (including its own Escape/Ctrl+Z/Ctrl+Y, mirrored from below) and
|
|
2001
|
+
// returns before any of the paragraph/heading branches run.
|
|
2002
|
+
if (currentBurst.blockType === 'list') {
|
|
2003
|
+
handleListKeydown(e, editEl);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (e.key === 'Enter') {
|
|
2007
|
+
e.preventDefault();
|
|
2008
|
+
if (e.shiftKey) {
|
|
2009
|
+
insertBrAtCaret();
|
|
2010
|
+
snapBurstIfActive(editEl, 'br');
|
|
2011
|
+
} else {
|
|
2012
|
+
editEl.blur();
|
|
2013
|
+
}
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (e.key === 'Escape') {
|
|
2017
|
+
e.preventDefault();
|
|
2018
|
+
revertBurstAndEnd(editEl);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
2022
|
+
e.preventDefault();
|
|
2023
|
+
burstUndo(editEl);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
|
2027
|
+
e.preventDefault();
|
|
2028
|
+
burstRedo(editEl);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// ── Task 4 (Phase 3): list item structural editing ─────────────────────
|
|
2034
|
+
// Enter = split into a new sibling item at the caret; Shift+Enter = <br>
|
|
2035
|
+
// (same as paragraph/heading); Tab = indent (child of previous sibling,
|
|
2036
|
+
// no-op with none); Shift+Tab = outdent (moves after the parent item,
|
|
2037
|
+
// no-op at top level); Enter on an EMPTY item removes it AND ends the
|
|
2038
|
+
// burst (commits) — every other empty-item-preserving Enter/Tab/Shift+Tab
|
|
2039
|
+
// is a purely local DOM mutation that keeps the burst open (multiple
|
|
2040
|
+
// splits/indents can happen in one sustained editing session), followed
|
|
2041
|
+
// by history.snap() per the Global Constraint ("every structural mutation
|
|
2042
|
+
// -> history snap").
|
|
2043
|
+
|
|
2044
|
+
// Nearest ancestor <li> of `node` (inclusive), never crossing `root` —
|
|
2045
|
+
// same walk-up pattern as closestMarkAncestor() above, specialized to LI.
|
|
2046
|
+
function closestListItem(node, root) {
|
|
2047
|
+
let n = node;
|
|
2048
|
+
while (n && n !== root) {
|
|
2049
|
+
if (n.nodeType === 1 && n.nodeName === 'LI') return n;
|
|
2050
|
+
n = n.parentNode;
|
|
2051
|
+
}
|
|
2052
|
+
return null;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function caretListItem(root) {
|
|
2056
|
+
const sel = window.getSelection();
|
|
2057
|
+
if (!sel.rangeCount) return null;
|
|
2058
|
+
return closestListItem(sel.getRangeAt(0).startContainer, root);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Task 4 fix (review, Critical): a NON-collapsed selection whose two
|
|
2062
|
+
// boundary points resolve to DIFFERENT <li> elements (or either resolves
|
|
2063
|
+
// to none) has no defined split semantics under the brief's caret-based
|
|
2064
|
+
// Enter contract — splitListItemAtCaret()'s Range extractContents() was
|
|
2065
|
+
// anchored only to the START container's own <li>, so a cross-item
|
|
2066
|
+
// selection silently deleted whatever the selection covered in the OTHER
|
|
2067
|
+
// item(s) before the (wrong) split ran. True only for a genuinely
|
|
2068
|
+
// cross-item selection; a same-item multi-character selection is still a
|
|
2069
|
+
// normal (delete-then-split) Enter, handled by splitListItemAtCaret()
|
|
2070
|
+
// itself.
|
|
2071
|
+
function selectionSpansMultipleListItems(root) {
|
|
2072
|
+
const sel = window.getSelection();
|
|
2073
|
+
if (!sel.rangeCount) return false;
|
|
2074
|
+
const range = sel.getRangeAt(0);
|
|
2075
|
+
if (range.collapsed) return false;
|
|
2076
|
+
const startLi = closestListItem(range.startContainer, root);
|
|
2077
|
+
const endLi = closestListItem(range.endContainer, root);
|
|
2078
|
+
return !startLi || !endLi || startLi !== endLi;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// Any UL/OL that is a direct child of `li` — per list-md.js's documented
|
|
2082
|
+
// DOM shape, a nested sublist (if any) is always exactly one such
|
|
2083
|
+
// trailing child; scanning ALL children (not just the last) is defensive
|
|
2084
|
+
// against an edit having transiently left it somewhere else.
|
|
2085
|
+
function directNestedListOf(li) {
|
|
2086
|
+
for (let i = 0; i < li.childNodes.length; i++) {
|
|
2087
|
+
const c = li.childNodes[i];
|
|
2088
|
+
if (c.nodeType === 1 && (c.nodeName === 'UL' || c.nodeName === 'OL')) return c;
|
|
2089
|
+
}
|
|
2090
|
+
return null;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// Task 8: `li`'s own nested list whose tag is exactly `nodeName` ('UL'/'OL'),
|
|
2094
|
+
// or null. outdentListItem() below needs the TYPE-MATCHED sublist, not merely
|
|
2095
|
+
// the first one: appending adopted items into a sublist of the other type
|
|
2096
|
+
// silently rewrites their markers (a bullet adopted into an <ol> comes back
|
|
2097
|
+
// as '1.'). Emitting a second sublist of the other type instead is fine —
|
|
2098
|
+
// list-md.js's serializeListNode() iterates every nested list of an item.
|
|
2099
|
+
function directNestedListOfType(li, nodeName) {
|
|
2100
|
+
for (let i = 0; i < li.childNodes.length; i++) {
|
|
2101
|
+
const c = li.childNodes[i];
|
|
2102
|
+
if (c.nodeType === 1 && c.nodeName === nodeName) return c;
|
|
2103
|
+
}
|
|
2104
|
+
return null;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// Task 8: the per-li edit surface (`<div class="ed-li-text">`, see
|
|
2108
|
+
// lib/md2doc.js's renderEditModeList) that holds `li`'s own inline content.
|
|
2109
|
+
// Falls back to the <li> itself for the pre-per-li bare shape, so the
|
|
2110
|
+
// structural helpers below keep working against either DOM.
|
|
2111
|
+
function liTextEl(li) {
|
|
2112
|
+
for (let i = 0; i < li.childNodes.length; i++) {
|
|
2113
|
+
const c = li.childNodes[i];
|
|
2114
|
+
if (c.nodeType === 1 && c.nodeName === 'DIV' &&
|
|
2115
|
+
c.classList && c.classList.contains('ed-li-text')) return c;
|
|
2116
|
+
}
|
|
2117
|
+
return li;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// Task 8: the non-editable checkbox chrome (spec §6) of `li`, if any.
|
|
2121
|
+
function liCheckEl(li) {
|
|
2122
|
+
for (let i = 0; i < li.childNodes.length; i++) {
|
|
2123
|
+
const c = li.childNodes[i];
|
|
2124
|
+
if (c.nodeType === 1 && c.nodeName === 'SPAN' &&
|
|
2125
|
+
c.classList && c.classList.contains('ed-li-check')) return c;
|
|
2126
|
+
}
|
|
2127
|
+
return null;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// An item is "empty" (brief: "Enter on EMPTY item = remove it") when it
|
|
2131
|
+
// has no nested sublist (removing it would orphan real content — refuse
|
|
2132
|
+
// that case rather than silently dropping children) and its own text is
|
|
2133
|
+
// blank (covers a bare placeholder <br> too — a <br>-only li's
|
|
2134
|
+
// textContent is '').
|
|
2135
|
+
// LEGACY (pre-per-li): used only by handleListKeydown()'s whole-list surface,
|
|
2136
|
+
// which is itself already unreachable (blockmap emits no type:'list' blocks in
|
|
2137
|
+
// the per-li architecture, so no burst can have blockType 'list' — see
|
|
2138
|
+
// resolveBurst()'s own LEGACY note); kept because that surface's empty-Enter
|
|
2139
|
+
// REMOVES the item, which is why the sublist refusal is still correct there.
|
|
2140
|
+
// The per-li path uses liOwnTextIsBlank() below; see RULING F-Q on its own
|
|
2141
|
+
// comment for why the two must differ.
|
|
2142
|
+
function isEmptyListItem(li) {
|
|
2143
|
+
if (directNestedListOf(li)) return false;
|
|
2144
|
+
return li.textContent.replace(/ /g, ' ').trim() === '';
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Task 8 / RULING F-Q: "empty" for the PER-LI Enter contract (spec §11 row 3)
|
|
2148
|
+
// means the item's OWN text is blank. A nested sublist does NOT disqualify it,
|
|
2149
|
+
// unlike isEmptyListItem() above: that predicate guards a path which REMOVES
|
|
2150
|
+
// the item, where refusing is the only way not to orphan its children, while
|
|
2151
|
+
// row 3's press OUTDENTS the item and the subtree travels with it — so there
|
|
2152
|
+
// is nothing to orphan. Spec §4 / §11 row 3 state the outdent with no
|
|
2153
|
+
// carve-out, so gating row 3 on isEmptyListItem() silently sent an empty
|
|
2154
|
+
// item that owned a sublist to the row-1 SPLIT instead (two empty items, the
|
|
2155
|
+
// subtree re-parented under the second).
|
|
2156
|
+
//
|
|
2157
|
+
// "Own text" is the `.ed-li-text` surface's text, which by construction
|
|
2158
|
+
// excludes the nested list (a sibling of that div inside the <li>). NBSP is
|
|
2159
|
+
// normalised to a space so a surface holding only a non-breaking space still
|
|
2160
|
+
// counts as blank, and a bare placeholder <br> counts too (its textContent is
|
|
2161
|
+
// '') — both carried over from isEmptyListItem().
|
|
2162
|
+
function liOwnTextIsBlank(li) {
|
|
2163
|
+
const textEl = liTextEl(li);
|
|
2164
|
+
if (textEl !== li) return textEl.textContent.replace(/ /g, ' ').trim() === '';
|
|
2165
|
+
// Bare (pre-per-li) shape: no wrapper div, so sum the item's own non-list
|
|
2166
|
+
// children explicitly rather than reading li.textContent, which would
|
|
2167
|
+
// include every descendant item's text.
|
|
2168
|
+
let text = '';
|
|
2169
|
+
for (let i = 0; i < li.childNodes.length; i++) {
|
|
2170
|
+
const c = li.childNodes[i];
|
|
2171
|
+
if (c.nodeName !== 'UL' && c.nodeName !== 'OL') text += c.textContent;
|
|
2172
|
+
}
|
|
2173
|
+
return text.replace(/ /g, ' ').trim() === '';
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// Task 8 / RULING F-U: true when `el` holds nothing any serializer would emit
|
|
2177
|
+
// — only whitespace text (NBSP included, matching liOwnTextIsBlank()'s own
|
|
2178
|
+
// normalisation) and placeholder <br>s. Deliberately stricter than
|
|
2179
|
+
// "textContent === ''", which the pre-F-U clear used: a void ELEMENT (an <img>
|
|
2180
|
+
// or <video>) also has empty textContent, and clearing innerHTML on it is data
|
|
2181
|
+
// loss. Such void elements are unsupported by the inline serializer, so they
|
|
2182
|
+
// make their li unsupported and the run-wide gate refuses structural keys before
|
|
2183
|
+
// this is ever reached for them. However, the predicate is intentionally
|
|
2184
|
+
// non-recursive: it returns false for ANY non-BR element, including supported
|
|
2185
|
+
// inline wrappers like <div> (which Chromium can leave as an empty-line
|
|
2186
|
+
// construct). The run-wide gate does NOT refuse structural keys for runs where
|
|
2187
|
+
// all lis are supported, so this conservative stance — treat any non-BR element
|
|
2188
|
+
// as "holds something" — is the correct safety net here. In practice Chromium
|
|
2189
|
+
// collapses empty-wrapper shapes (e.g. <div><br></div>) to a bare <br> after
|
|
2190
|
+
// full-content deletion, which the predicate already handles correctly.
|
|
2191
|
+
function liSurfaceHoldsNothing(el) {
|
|
2192
|
+
for (let i = 0; i < el.childNodes.length; i++) {
|
|
2193
|
+
const c = el.childNodes[i];
|
|
2194
|
+
if (c.nodeType === 1 && c.nodeName !== 'BR') return false;
|
|
2195
|
+
if (c.nodeType === 3 && c.textContent.replace(/\u00a0/g, ' ').trim() !== '') return false;
|
|
2196
|
+
}
|
|
2197
|
+
return true;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Splits `li` into two siblings at the caret via Range surgery — the same
|
|
2201
|
+
// extractContents()-based pattern wrapRangeIn() above already uses, so
|
|
2202
|
+
// inline formatting (a caret mid-<strong>, say) splits cleanly instead of
|
|
2203
|
+
// being torn.
|
|
2204
|
+
//
|
|
2205
|
+
// Task 8 (per-li arch): the caret lives inside `li`'s own
|
|
2206
|
+
// `<div class="ed-li-text">` surface, not directly under the <li>, so the
|
|
2207
|
+
// tail range runs to the END OF THAT DIV and the new sibling gets a
|
|
2208
|
+
// .ed-li-text div of its own to hold it. The provisional <li> deliberately
|
|
2209
|
+
// carries NO data-block-id / data-indent / data-list-type: list-md.js reads
|
|
2210
|
+
// those only for per-li unsupported ATTRIBUTION, and the very next
|
|
2211
|
+
// commitListStructure() + re-render replaces it with a real, server-numbered
|
|
2212
|
+
// block anyway. A `.ed-li-check` sibling IS reproduced (unchecked) so that
|
|
2213
|
+
// splitting a task item yields another task item rather than silently
|
|
2214
|
+
// converting the tail half to a plain bullet.
|
|
2215
|
+
//
|
|
2216
|
+
// A trailing nested sublist travels with the NEW (second) item — per
|
|
2217
|
+
// list-md.js's documented shape it is always `li`'s last child, i.e. it
|
|
2218
|
+
// physically follows the caret, so this is the same deterministic
|
|
2219
|
+
// "whichever half it follows in DOM order" rule the pre-Task-8 version had,
|
|
2220
|
+
// and it matches the spec's Enter contract (the new block inherits the
|
|
2221
|
+
// subtree).
|
|
2222
|
+
//
|
|
2223
|
+
// Returns the new <li>, or null when the caret is not inside `li`'s own
|
|
2224
|
+
// surface (nothing mutated).
|
|
2225
|
+
function splitListItemAtCaret(li) {
|
|
2226
|
+
const sel = window.getSelection();
|
|
2227
|
+
if (!sel.rangeCount) return null;
|
|
2228
|
+
const textEl = liTextEl(li);
|
|
2229
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
2230
|
+
// Containment is checked BEFORE deleteContents() so the refusal below is a
|
|
2231
|
+
// true no-op rather than "the selection was deleted, then we gave up".
|
|
2232
|
+
if (range.startContainer !== textEl && !textEl.contains(range.startContainer)) return null;
|
|
2233
|
+
if (!range.collapsed) range.deleteContents(); // collapses to the start point
|
|
2234
|
+
const tailRange = document.createRange();
|
|
2235
|
+
tailRange.setStart(range.startContainer, range.startOffset);
|
|
2236
|
+
tailRange.setEnd(textEl, textEl.childNodes.length);
|
|
2237
|
+
const tailFrag = tailRange.extractContents();
|
|
2238
|
+
const newLi = document.createElement('li');
|
|
2239
|
+
const check = liCheckEl(li);
|
|
2240
|
+
if (check) {
|
|
2241
|
+
const newCheck = check.cloneNode(false);
|
|
2242
|
+
newCheck.setAttribute('data-checked', '0');
|
|
2243
|
+
newCheck.setAttribute('aria-checked', 'false');
|
|
2244
|
+
newLi.appendChild(newCheck);
|
|
2245
|
+
}
|
|
2246
|
+
if (textEl === li) {
|
|
2247
|
+
// Pre-per-li bare shape: no .ed-li-text wrapper to reproduce.
|
|
2248
|
+
newLi.appendChild(tailFrag);
|
|
2249
|
+
} else {
|
|
2250
|
+
const newText = document.createElement('div');
|
|
2251
|
+
newText.className = 'ed-li-text';
|
|
2252
|
+
newText.appendChild(tailFrag);
|
|
2253
|
+
newLi.appendChild(newText);
|
|
2254
|
+
}
|
|
2255
|
+
const sub = directNestedListOf(li);
|
|
2256
|
+
if (sub) newLi.appendChild(sub);
|
|
2257
|
+
li.parentNode.insertBefore(newLi, li.nextSibling);
|
|
2258
|
+
return newLi;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// Removes `li` from its list. If that empties out a NESTED sublist (never
|
|
2262
|
+
// the burst's own root list — editEl's own parent is the block <div>, not
|
|
2263
|
+
// an <li>, so this never touches the root), the now-empty <ul>/<ol> is
|
|
2264
|
+
// cleaned up too rather than left dangling.
|
|
2265
|
+
function removeListItem(li) {
|
|
2266
|
+
const parentList = li.parentNode;
|
|
2267
|
+
parentList.removeChild(li);
|
|
2268
|
+
if (parentList.childElementCount === 0 &&
|
|
2269
|
+
parentList.parentNode && parentList.parentNode.nodeName === 'LI') {
|
|
2270
|
+
parentList.parentNode.removeChild(parentList);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// Tab: `li` becomes the LAST child of its previous sibling's own nested sublist
|
|
2275
|
+
// of the SAME ordered/unordered type as the list `li` is moving out of
|
|
2276
|
+
// (creating one when `prev` has no type-matched sublist). No previous sibling
|
|
2277
|
+
// -> no-op (brief). Returns true iff a mutation actually happened, so the
|
|
2278
|
+
// caller only snaps history on a real change.
|
|
2279
|
+
//
|
|
2280
|
+
// RULING F-T: the target must be type-matched, and the type that matters is
|
|
2281
|
+
// the MOVING item's own current list — not whichever sublist `prev` happens to
|
|
2282
|
+
// own first. `directNestedListOf(prev)` returned that first sublist regardless
|
|
2283
|
+
// of its tag, so Tab on a bullet whose previous sibling owned an <ol> appended
|
|
2284
|
+
// the bullet into that <ol>, and list-md.js derives an item's marker from its
|
|
2285
|
+
// list node (serializeListNode()'s `ordered`) — silently re-emitting an item
|
|
2286
|
+
// the user never touched as '1.'/'2.'. Identical root cause to
|
|
2287
|
+
// outdentListItem()'s adoption target below; see directNestedListOfType().
|
|
2288
|
+
function indentListItem(li) {
|
|
2289
|
+
const prev = li.previousElementSibling;
|
|
2290
|
+
if (!prev || prev.nodeName !== 'LI') return false;
|
|
2291
|
+
const listTag = li.parentNode.nodeName; // captured before the move detaches li
|
|
2292
|
+
let nested = directNestedListOfType(prev, listTag);
|
|
2293
|
+
if (!nested) {
|
|
2294
|
+
nested = document.createElement(listTag === 'OL' ? 'ol' : 'ul');
|
|
2295
|
+
prev.appendChild(nested);
|
|
2296
|
+
}
|
|
2297
|
+
li.parentNode.removeChild(li);
|
|
2298
|
+
nested.appendChild(li);
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Shift+Tab (spec §11 row 6, user-verified against Notion): `li` moves out
|
|
2303
|
+
// to become the NEXT sibling of the <li> that owns its current list, and its
|
|
2304
|
+
// former FOLLOWING same-level siblings are ADOPTED as its children. Top
|
|
2305
|
+
// level (no owning <li>) -> no-op (row 8).
|
|
2306
|
+
//
|
|
2307
|
+
// Task 8 replaces the pre-Task-8 "siblings stay" rule. Why adoption is the
|
|
2308
|
+
// right shape: the outdented item rises one column, so any item that used to
|
|
2309
|
+
// follow it at the OLD level would otherwise have to rise with it (losing
|
|
2310
|
+
// its relationship to the item above it) or stay put and become a sibling of
|
|
2311
|
+
// the item it used to follow. Notion's answer — and the spec's — is that
|
|
2312
|
+
// those items keep their exact visual indent and become children of the
|
|
2313
|
+
// item that just passed them. That is also the only one of the three
|
|
2314
|
+
// outcomes that is a pure re-parenting: no item's rendered indent column
|
|
2315
|
+
// changes except `li`'s own.
|
|
2316
|
+
//
|
|
2317
|
+
// Two hazards this walk is written around (both real against server-rendered
|
|
2318
|
+
// list HTML, and both silent if got wrong):
|
|
2319
|
+
// 1. The list carries marked's pretty-print "\n" text nodes BETWEEN items.
|
|
2320
|
+
// A blanket "remove every following node" loop would drop them (which is
|
|
2321
|
+
// harmless — list-md.js treats them as insignificant, see its
|
|
2322
|
+
// isBlankText()) but the same loop would ALSO drop any following node
|
|
2323
|
+
// that is neither an <li> nor whitespace, i.e. silently delete real
|
|
2324
|
+
// content. So only <li>s (collected) and blank text nodes (discarded)
|
|
2325
|
+
// are detached; anything else is left exactly where it is. Such a node
|
|
2326
|
+
// makes the whole run unsupported anyway (serializeList() flags a
|
|
2327
|
+
// non-LI child of a UL/OL), so listRunSupportsStructuralEdit() has
|
|
2328
|
+
// already refused the key before this function is reached — this is
|
|
2329
|
+
// defense in depth, not a live path.
|
|
2330
|
+
// 2. The emptied parent list is removed only when it has no ELEMENT
|
|
2331
|
+
// children left — `childElementCount === 0`, i.e. the pre-Task-8 test,
|
|
2332
|
+
// which was already right. (`childNodes.length` would NOT be: the
|
|
2333
|
+
// leading "\n" text node in front of the moved item always survives, so
|
|
2334
|
+
// the list is never empty by NODE count even when it holds nothing.
|
|
2335
|
+
// childElementCount counts elements only, so whitespace text nodes are
|
|
2336
|
+
// already invisible to it.) The distinction is load-bearing because the
|
|
2337
|
+
// two candidate predicates differ in exactly one case — a non-LI ELEMENT
|
|
2338
|
+
// left behind in the list — and there childElementCount KEEPS the list,
|
|
2339
|
+
// which is what preserves the very node hazard 1 above deliberately
|
|
2340
|
+
// declined to move. A "still has an <li> child" test would instead have
|
|
2341
|
+
// deleted the list with that node inside it, making hazard 1's care
|
|
2342
|
+
// self-defeating.
|
|
2343
|
+
//
|
|
2344
|
+
// Adoption target (third silent failure mode): the followers are appended into
|
|
2345
|
+
// `li`'s own sublist of the SAME type as the list they came from, creating one
|
|
2346
|
+
// if `li` has no matching sublist. Reusing whatever sublist `li` happened to
|
|
2347
|
+
// have would rewrite the adopted items' markers — a bullet adopted into an
|
|
2348
|
+
// <ol> comes back as '1.'. See directNestedListOfType().
|
|
2349
|
+
function outdentListItem(li) {
|
|
2350
|
+
const parentList = li.parentNode;
|
|
2351
|
+
const grandLi = parentList.parentNode;
|
|
2352
|
+
if (!grandLi || grandLi.nodeName !== 'LI') return false;
|
|
2353
|
+
const grandList = grandLi.parentNode;
|
|
2354
|
+
// Notion adoption: former following siblings become `li`'s own children.
|
|
2355
|
+
const followers = [];
|
|
2356
|
+
let n = li.nextSibling;
|
|
2357
|
+
while (n) {
|
|
2358
|
+
const next = n.nextSibling;
|
|
2359
|
+
if (n.nodeName === 'LI') {
|
|
2360
|
+
followers.push(n);
|
|
2361
|
+
parentList.removeChild(n);
|
|
2362
|
+
} else if (n.nodeType === 3 && /^\s*$/.test(n.textContent)) {
|
|
2363
|
+
parentList.removeChild(n); // marked's pretty-print artifact — see hazard 1
|
|
2364
|
+
}
|
|
2365
|
+
n = next;
|
|
2366
|
+
}
|
|
2367
|
+
if (followers.length) {
|
|
2368
|
+
let sub = directNestedListOfType(li, parentList.nodeName);
|
|
2369
|
+
if (!sub) {
|
|
2370
|
+
sub = document.createElement(parentList.nodeName === 'OL' ? 'ol' : 'ul');
|
|
2371
|
+
li.appendChild(sub);
|
|
2372
|
+
}
|
|
2373
|
+
followers.forEach((f) => sub.appendChild(f));
|
|
2374
|
+
}
|
|
2375
|
+
parentList.removeChild(li);
|
|
2376
|
+
grandList.insertBefore(li, grandLi.nextSibling);
|
|
2377
|
+
if (parentList.childElementCount === 0) grandLi.removeChild(parentList); // see hazard 2
|
|
2378
|
+
return true;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function handleListKeydown(e, editEl) {
|
|
2382
|
+
if (e.key === 'Enter') {
|
|
2383
|
+
e.preventDefault();
|
|
2384
|
+
if (e.shiftKey) {
|
|
2385
|
+
insertBrAtCaret();
|
|
2386
|
+
snapBurstIfActive(editEl, 'br');
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
const li = caretListItem(editEl);
|
|
2390
|
+
if (!li) return;
|
|
2391
|
+
if (selectionSpansMultipleListItems(editEl)) {
|
|
2392
|
+
// Refuse rather than silently deleting the spanned content — no
|
|
2393
|
+
// mutation, no history snap. Collapse to the end of the selection
|
|
2394
|
+
// so a repeat Enter (now a plain caret) behaves predictably.
|
|
2395
|
+
// (Explicit removeAllRanges()/addRange() — same pattern every other
|
|
2396
|
+
// Range-mutation in this file uses — rather than mutating the Range
|
|
2397
|
+
// returned by getRangeAt() in place, which isn't guaranteed to sync
|
|
2398
|
+
// back to the live Selection.)
|
|
2399
|
+
const sel = window.getSelection();
|
|
2400
|
+
if (sel.rangeCount) {
|
|
2401
|
+
const r = sel.getRangeAt(0).cloneRange();
|
|
2402
|
+
r.collapse(false);
|
|
2403
|
+
sel.removeAllRanges();
|
|
2404
|
+
sel.addRange(r);
|
|
2405
|
+
}
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (isEmptyListItem(li)) {
|
|
2409
|
+
removeListItem(li);
|
|
2410
|
+
snapBurstIfActive(editEl, 'list-remove');
|
|
2411
|
+
editEl.blur(); // ends the burst -> commits, per the brief
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
splitListItemAtCaret(li);
|
|
2415
|
+
snapBurstIfActive(editEl, 'list-split');
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
if (e.key === 'Tab') {
|
|
2419
|
+
e.preventDefault();
|
|
2420
|
+
const li = caretListItem(editEl);
|
|
2421
|
+
if (!li) return;
|
|
2422
|
+
const changed = e.shiftKey ? outdentListItem(li) : indentListItem(li);
|
|
2423
|
+
if (changed) {
|
|
2424
|
+
placeCaretAtEnd(li);
|
|
2425
|
+
snapBurstIfActive(editEl, e.shiftKey ? 'list-outdent' : 'list-indent');
|
|
2426
|
+
}
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (e.key === 'Escape') {
|
|
2430
|
+
e.preventDefault();
|
|
2431
|
+
revertBurstAndEnd(editEl);
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
2435
|
+
e.preventDefault();
|
|
2436
|
+
burstUndo(editEl);
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
|
2440
|
+
e.preventDefault();
|
|
2441
|
+
burstRedo(editEl);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
|
|
2447
|
+
// Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
|
|
2448
|
+
// 8. Structurally different from Task 4's whole-list handleListKeydown()
|
|
2449
|
+
// above (which stays for the legacy 'list' surface): there, a key mutated
|
|
2450
|
+
// one big contenteditable and the commit waited for focusout. Here each li
|
|
2451
|
+
// is its own block AND its own surface, so a provisional <li> is not a real
|
|
2452
|
+
// block until the run is committed and re-rendered — every mutating key
|
|
2453
|
+
// therefore commits immediately (spec §3: "any structural change
|
|
2454
|
+
// re-serializes the whole run → one line-range replace"), which is also what
|
|
2455
|
+
// keeps each key at exactly ONE undo op.
|
|
2456
|
+
//
|
|
2457
|
+
// The one documented exception is row 3's TOP-LEVEL press (see
|
|
2458
|
+
// convertEmptyTopLevelLiToParagraph() below and RULING F-J).
|
|
2459
|
+
|
|
2460
|
+
// Shared refusal for a structural key on a run that cannot round-trip —
|
|
2461
|
+
// see listRunSupportsStructuralEdit().
|
|
2462
|
+
function refuseStructuralListEdit() {
|
|
2463
|
+
showBanner('此清單含不支援的格式,無法調整結構', null, null);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
2467
|
+
// the block to a paragraph". Markdown cannot persist an EMPTY paragraph, so
|
|
2468
|
+
// this reuses the repo's existing §10 pristine-insert machinery instead of
|
|
2469
|
+
// inventing a second empty-block representation: remove the li (commit #1,
|
|
2470
|
+
// the run re-serialize), then insertBlockBelow() the paragraph skeleton
|
|
2471
|
+
// anchored to whatever block now PRECEDES the removal point (commit #2). The
|
|
2472
|
+
// user lands in a focused provisional paragraph that becomes real the moment
|
|
2473
|
+
// anything is typed into it, and self-removes at zero net undo cost if
|
|
2474
|
+
// abandoned (discardPristineInsert()).
|
|
2475
|
+
//
|
|
2476
|
+
// RULING F-J: this is therefore the ONE structural key press that is not a
|
|
2477
|
+
// single undo op. Observed granularity (asserted in
|
|
2478
|
+
// test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
|
|
2479
|
+
// paragraph without popping the stack, Ctrl+Z #2 reverts the li removal.
|
|
2480
|
+
async function convertEmptyTopLevelLiToParagraph(root, li) {
|
|
2481
|
+
// Both captured BEFORE the mutation. The range, because removing the run's
|
|
2482
|
+
// last item leaves commitListStructure() nothing to derive it from. The
|
|
2483
|
+
// anchor, because a removal never shifts a block that starts ahead of it,
|
|
2484
|
+
// and the run's re-serialization only rewrites lines from the run's own
|
|
2485
|
+
// start onward — so this startLine survives the commit and is the stable
|
|
2486
|
+
// handle back to that block (ids are re-derived by every render).
|
|
2487
|
+
const range = runRangeOf({ lines, blocks, stack }, root);
|
|
2488
|
+
const liBlock = blocks.find((b) => b.id === Number(li.getAttribute('data-block-id')));
|
|
2489
|
+
const precedingBlock = liBlock
|
|
2490
|
+
? blocks.filter((b) => b.endLine < liBlock.startLine).pop()
|
|
2491
|
+
: null;
|
|
2492
|
+
mutateListRun(() => removeListItem(li));
|
|
2493
|
+
// The key's own surface is inside the li that was just removed, so it is
|
|
2494
|
+
// detached now — commit against the run ROOT (see commitListStructure()'s
|
|
2495
|
+
// `runEl` note).
|
|
2496
|
+
const ok = await commitListStructure(root, null, false, range);
|
|
2497
|
+
if (!ok) return;
|
|
2498
|
+
// Nothing precedes the removal point (the list opened the document):
|
|
2499
|
+
// commitBlockInsertion() can only insert BELOW an existing block, so the
|
|
2500
|
+
// paragraph step is skipped. The li removal still stands — no content is
|
|
2501
|
+
// lost, the user just has to type where they want the paragraph.
|
|
2502
|
+
if (!precedingBlock) return;
|
|
2503
|
+
const anchorEl = blockElAtLine(precedingBlock.startLine);
|
|
2504
|
+
if (!anchorEl) return;
|
|
2505
|
+
await insertBlockBelow(anchorEl, 'paragraph');
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// Returns true when the key was CONSUMED (Enter/Tab, incl. their no-op
|
|
2509
|
+
// outcomes); false lets handleBurstKeydown()'s shared Escape / Ctrl+Z /
|
|
2510
|
+
// Ctrl+Y branches run unchanged.
|
|
2511
|
+
function handleLiKeydown(e, editEl) {
|
|
2512
|
+
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
2513
|
+
e.preventDefault();
|
|
2514
|
+
// Row 2: Shift+Enter is an in-block line break, not a structural change.
|
|
2515
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
2516
|
+
insertBrAtCaret();
|
|
2517
|
+
snapBurstIfActive(editEl, 'br');
|
|
2518
|
+
return true;
|
|
2519
|
+
}
|
|
2520
|
+
const root = listRunRootOf(editEl);
|
|
2521
|
+
if (!root) return true;
|
|
2522
|
+
// The CARET's li, not editEl's: a run has one editable surface per item,
|
|
2523
|
+
// and the caret can legitimately sit in a different one than the burst was
|
|
2524
|
+
// opened on (placing a Range inside another li's surface does not move
|
|
2525
|
+
// focus). closestListItem(editEl) is the fallback when the selection is
|
|
2526
|
+
// absent or outside the run.
|
|
2527
|
+
const li = caretListItem(root) || closestListItem(editEl, root);
|
|
2528
|
+
if (!li) return true;
|
|
2529
|
+
|
|
2530
|
+
if (e.key === 'Tab') {
|
|
2531
|
+
if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return true; }
|
|
2532
|
+
// Row 5 (Tab): indentListItem() moves ONLY the caret item and its own
|
|
2533
|
+
// subtree — later siblings are untouched. Row 6 (Shift+Tab):
|
|
2534
|
+
// outdentListItem() raises it one level and adopts its former following
|
|
2535
|
+
// siblings. Rows 7/8: both return false at their respective boundary (no
|
|
2536
|
+
// previous sibling / already top level), which is a complete no-op —
|
|
2537
|
+
// nothing mutated, nothing committed, file byte-identical.
|
|
2538
|
+
const changed = mutateListRun(() => (e.shiftKey ? outdentListItem(li) : indentListItem(li)));
|
|
2539
|
+
if (!changed) return true;
|
|
2540
|
+
commitListStructure(editEl, runLineOfListItem(root, li), true);
|
|
2541
|
+
return true;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Enter.
|
|
2545
|
+
if (selectionSpansMultipleListItems(root)) {
|
|
2546
|
+
// Refuse rather than silently deleting the spanned content — no
|
|
2547
|
+
// mutation, no commit, no banner. Collapse to the end of the selection
|
|
2548
|
+
// so a repeat Enter (now a plain caret) behaves predictably.
|
|
2549
|
+
// (Explicit removeAllRanges()/addRange() — same pattern every other
|
|
2550
|
+
// Range-mutation in this file uses — rather than mutating the Range
|
|
2551
|
+
// returned by getRangeAt() in place, which isn't guaranteed to sync
|
|
2552
|
+
// back to the live Selection.)
|
|
2553
|
+
const sel = window.getSelection();
|
|
2554
|
+
if (sel.rangeCount) {
|
|
2555
|
+
const r = sel.getRangeAt(0).cloneRange();
|
|
2556
|
+
r.collapse(false);
|
|
2557
|
+
sel.removeAllRanges();
|
|
2558
|
+
sel.addRange(r);
|
|
2559
|
+
}
|
|
2560
|
+
return true;
|
|
2561
|
+
}
|
|
2562
|
+
if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return true; }
|
|
2563
|
+
if (liOwnTextIsBlank(li)) {
|
|
2564
|
+
// Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
|
|
2565
|
+
// (adoption included). RULING F-Q: an item that OWNS a sublist takes this
|
|
2566
|
+
// path too — its subtree travels with it, so there is nothing to orphan.
|
|
2567
|
+
const outdented = mutateListRun(() => {
|
|
2568
|
+
if (!outdentListItem(li)) return false;
|
|
2569
|
+
// The surface can still hold things the user reads as "nothing" but a
|
|
2570
|
+
// serializer does not: Chromium's placeholder <br> (left behind when the
|
|
2571
|
+
// last character is deleted), which inline-md.js emits as a literal
|
|
2572
|
+
// '<br>', and — RULING F-U — a bare NBSP, which liOwnTextIsBlank() above
|
|
2573
|
+
// normalises away but list-md.js's trailing-whitespace trim
|
|
2574
|
+
// (/[ \t]+$/) does not, so it would survive into the committed line.
|
|
2575
|
+
// Either way the user sees an empty item and must get a bare '-'. Clear
|
|
2576
|
+
// the surface under the SAME normalisation the branch condition used —
|
|
2577
|
+
// liSurfaceHoldsNothing() — inside the suppression span (an innerHTML
|
|
2578
|
+
// assignment on the focused node triggers the very same Chromium unfocus
|
|
2579
|
+
// quirk), and only once the outdent above has actually happened, since a
|
|
2580
|
+
// refused press must leave the DOM byte-identical.
|
|
2581
|
+
const textEl = liTextEl(li);
|
|
2582
|
+
if (textEl !== li && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
|
|
2583
|
+
return true;
|
|
2584
|
+
});
|
|
2585
|
+
if (outdented) {
|
|
2586
|
+
commitListStructure(editEl, runLineOfListItem(root, li), true);
|
|
2587
|
+
return true;
|
|
2588
|
+
}
|
|
2589
|
+
// Already at top level, so this is row 3's "next press converts the block
|
|
2590
|
+
// to a paragraph" step — EXCEPT when the item owns a sublist. RULING F-Q
|
|
2591
|
+
// draws the line here: a paragraph cannot own list children, so
|
|
2592
|
+
// converting would have to promote them to top-level items, which spec
|
|
2593
|
+
// row 3 never describes and which silently restructures content the user
|
|
2594
|
+
// did not touch. Refuse instead — a complete no-op (nothing mutated,
|
|
2595
|
+
// nothing committed, burst left open) until the user empties or moves the
|
|
2596
|
+
// children themselves.
|
|
2597
|
+
if (directNestedListOf(li)) return true;
|
|
2598
|
+
convertEmptyTopLevelLiToParagraph(root, li);
|
|
2599
|
+
return true;
|
|
2600
|
+
}
|
|
2601
|
+
// Row 1: split at the caret; the caret goes to the START of the new block.
|
|
2602
|
+
const newLi = mutateListRun(() => splitListItemAtCaret(li));
|
|
2603
|
+
if (!newLi) return true;
|
|
2604
|
+
commitListStructure(editEl, runLineOfListItem(root, newLi), false);
|
|
2605
|
+
return true;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// ── Task 5 (Phase 3): table always-on WYSIWYG editing + burst undo ─────
|
|
2609
|
+
// Retires Phase-2's click-select-then-✎ table session (the old per-table
|
|
2610
|
+
// opening function, now deleted entirely) in favor of the SAME always-on
|
|
2611
|
+
// burst substrate Task 2
|
|
2612
|
+
// (paragraph/heading) and Task 4 (list) already use — every cell of an
|
|
2613
|
+
// eligible table is permanently contenteditable from armEditables() (see
|
|
2614
|
+
// its 'table' branch above), no "open" step. A table's burst is the ONE
|
|
2615
|
+
// structural exception to "burst.editEl is the focused surface itself"
|
|
2616
|
+
// (true for paragraph/heading/list): a table has MANY independently-
|
|
2617
|
+
// editable cells, so burst.editEl is the whole <table> (matching what
|
|
2618
|
+
// tableMd.serializeTable() expects, and what commits as ONE line-range
|
|
2619
|
+
// replacement) while burst.activeCellEl tracks whichever cell most
|
|
2620
|
+
// recently had focus — Tab/click moving between cells updates
|
|
2621
|
+
// activeCellEl WITHOUT ending the burst; only focus leaving the TABLE
|
|
2622
|
+
// entirely (or Esc, or undo/redo cascading out) ends it. activeCellEl is
|
|
2623
|
+
// also what the Task 5 hover-insert bubbles below and T6's future edge
|
|
2624
|
+
// menus read to know which row/column an op should act on.
|
|
2625
|
+
//
|
|
2626
|
+
// Fixed Tab-navigation order: document order of every TH/TD, which for a
|
|
2627
|
+
// table (thead before tbody, rows/cells in source order) is exactly
|
|
2628
|
+
// header-row-left-to-right then each body row left-to-right. Real DOM
|
|
2629
|
+
// (not the node-test stub), so querySelectorAll is fair game here —
|
|
2630
|
+
// unlike table-md.js, this file has never been childNodes-only.
|
|
2631
|
+
function tableCellsOf(tableEl) {
|
|
2632
|
+
return Array.prototype.slice.call(tableEl.querySelectorAll('th, td'));
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// Starts a burst rooted at `tableEl` (any cell's arm-time class already
|
|
2636
|
+
// makes it a valid focus target) — mirrors startBurst() above, just with
|
|
2637
|
+
// an extra `activeCellEl` field and a captureFn that snapshots the WHOLE
|
|
2638
|
+
// table's innerHTML (which already includes every cell's contenteditable/
|
|
2639
|
+
// class attributes, set once at arm time — see armEditables()'s 'table'
|
|
2640
|
+
// branch — so a burst-undo snapshot restore below reproduces a fully
|
|
2641
|
+
// re-armed table, not a plain static one).
|
|
2642
|
+
function startTableBurst(cellEl) {
|
|
2643
|
+
const tableEl = cellEl.closest('table');
|
|
2644
|
+
const blockEl = tableEl && tableEl.closest('.ed-block');
|
|
2645
|
+
if (!blockEl) return;
|
|
2646
|
+
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
2647
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
2648
|
+
if (!block) return;
|
|
2649
|
+
const history = historyLib.createBurstHistory(() => tableEl.innerHTML, { debounceMs: 400 });
|
|
2650
|
+
history.start();
|
|
2651
|
+
currentBurst = {
|
|
2652
|
+
blockEl, editEl: tableEl, blockId, blockType: 'table',
|
|
2653
|
+
depth: null, original: tableEl.innerHTML, history,
|
|
2654
|
+
activeCellEl: cellEl,
|
|
2655
|
+
};
|
|
2656
|
+
selToolbarEditEl = cellEl;
|
|
2657
|
+
if (!selToolbarListener) {
|
|
2658
|
+
selToolbarListener = onSelectionChangeForToolbar;
|
|
2659
|
+
document.addEventListener('selectionchange', onSelectionChangeForToolbar);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// The delegated focusin listener's table-cell branch (called instead of
|
|
2664
|
+
// the plain paragraph/list path below whenever the focused target is a
|
|
2665
|
+
// '.ed-wys-cell') — mirrors that path's single-flight / re-resolve-after-
|
|
2666
|
+
// rerenderAll() shape exactly, just keyed on the CELL's owning table
|
|
2667
|
+
// rather than the cell itself (so moving focus between cells of the same
|
|
2668
|
+
// table's already-open burst is a no-op here, not a new burst).
|
|
2669
|
+
async function handleTableCellFocusIn(cellEl) {
|
|
2670
|
+
const tableEl = cellEl.closest('table');
|
|
2671
|
+
if (!tableEl) return;
|
|
2672
|
+
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) {
|
|
2673
|
+
currentBurst.activeCellEl = cellEl; // burst already open — just the active cell moved
|
|
2674
|
+
selToolbarEditEl = cellEl;
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
const blockEl = tableEl.closest('.ed-block');
|
|
2678
|
+
const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
2679
|
+
if (switching) await switching;
|
|
2680
|
+
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.blockEl &&
|
|
2681
|
+
currentBurst.blockEl.getAttribute('data-block-id') === blockId) {
|
|
2682
|
+
currentBurst.activeCellEl = cellEl;
|
|
2683
|
+
selToolbarEditEl = cellEl;
|
|
2684
|
+
return; // the awaited resolution's own re-entrant focus already won this table's burst
|
|
2685
|
+
}
|
|
2686
|
+
if (currentBurst) return; // a concurrent focusin (a DIFFERENT block) already won the race
|
|
2687
|
+
let liveCellEl = cellEl;
|
|
2688
|
+
if (!document.body.contains(cellEl)) {
|
|
2689
|
+
const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
|
|
2690
|
+
const liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
2691
|
+
liveCellEl = liveTableEl ? tableCellsOf(liveTableEl)[0] : null;
|
|
2692
|
+
if (!liveCellEl || !liveCellEl.classList.contains('ed-wys-cell')) return;
|
|
2693
|
+
liveCellEl.focus();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
startTableBurst(liveCellEl);
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function moveActiveTableCell(cellEl, delta) {
|
|
2700
|
+
const tableEl = cellEl.closest('table');
|
|
2701
|
+
const cells = tableCellsOf(tableEl);
|
|
2702
|
+
const idx = cells.indexOf(cellEl);
|
|
2703
|
+
const next = Math.max(0, Math.min(cells.length - 1, idx + delta));
|
|
2704
|
+
const target = cells[next];
|
|
2705
|
+
if (target) { target.focus(); placeCaretAtEnd(target); }
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// Esc inside a table burst: revert to the pre-focus baseline and end the
|
|
2709
|
+
// burst WITHOUT committing — mirrors revertBurstAndEnd() above, but a
|
|
2710
|
+
// table burst has no single always-live element to blur() afterward (the
|
|
2711
|
+
// innerHTML rewrite below detaches whichever cell WAS focused, and
|
|
2712
|
+
// replaces it with an equivalent-but-different node). Chromium runs the
|
|
2713
|
+
// focus-fixup "unfocus" step (firing a synchronous blur/focusout) BEFORE
|
|
2714
|
+
// actually detaching the node — see `suppressTableFocusout`'s own
|
|
2715
|
+
// comment near `currentBurst`'s declaration for the full story — so
|
|
2716
|
+
// `e.target.closest('table')` in that focusout would still resolve to
|
|
2717
|
+
// this live `tableEl`. What makes THIS call site safe WITHOUT that flag
|
|
2718
|
+
// is nulling `currentBurst` first: the focusout handler's table branch
|
|
2719
|
+
// requires `currentBurst` to be non-null, so by the time the innerHTML
|
|
2720
|
+
// rewrite below fires that synchronous blur, there is no burst left to
|
|
2721
|
+
// mistakenly resolve — same "belt and braces" idiom rerenderAll() uses.
|
|
2722
|
+
async function revertTableBurstAndEnd(cellEl) {
|
|
2723
|
+
const burst = currentBurst;
|
|
2724
|
+
if (!burst || burst.blockType !== 'table' || burst.editEl !== cellEl.closest('table')) return;
|
|
2725
|
+
const tableEl = burst.editEl;
|
|
2726
|
+
// §10-gap fix (review): same "Escape always reverts to `burst.original`,
|
|
2727
|
+
// which for a pristine block IS the still-untouched skeleton" reasoning
|
|
2728
|
+
// as revertBurstAndEnd() above.
|
|
2729
|
+
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
2730
|
+
if (wasPristineForThisBlock) pristineInsert = null;
|
|
2731
|
+
burst.history.dispose();
|
|
2732
|
+
currentBurst = null;
|
|
2733
|
+
resetSelToolbarState();
|
|
2734
|
+
hideTableInsertBubbles();
|
|
2735
|
+
hideTableGrips();
|
|
2736
|
+
hideTableEdgeMenu();
|
|
2737
|
+
cancelTeDrag();
|
|
2738
|
+
tableEl.innerHTML = burst.original;
|
|
2739
|
+
if (wasPristineForThisBlock) await discardPristineInsert();
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// Ctrl+Z inside a table burst: symmetric to burstUndo() above, but a
|
|
2743
|
+
// table-history snapshot replaces the WHOLE table's innerHTML (not one
|
|
2744
|
+
// focusable surface), so the previously-active cell's DOM node is stale
|
|
2745
|
+
// after restore — re-focus the cell at the SAME ordinal position (by
|
|
2746
|
+
// index among tableCellsOf()) in the freshly-restored DOM instead.
|
|
2747
|
+
function tableBurstUndo(cellEl) {
|
|
2748
|
+
const burst = currentBurst;
|
|
2749
|
+
if (!burst || burst.blockType !== 'table' || burst.editEl !== cellEl.closest('table')) return;
|
|
2750
|
+
const tableEl = burst.editEl;
|
|
2751
|
+
const cells = tableCellsOf(tableEl);
|
|
2752
|
+
const idx = cells.indexOf(burst.activeCellEl || cellEl);
|
|
2753
|
+
const state = burst.history.undo();
|
|
2754
|
+
if (state !== null) {
|
|
2755
|
+
// Task 6: the innerHTML swap below detaches whatever cells/rows the
|
|
2756
|
+
// grip handles, the edge menu, or an in-flight drag currently
|
|
2757
|
+
// reference on THIS table — clear all three before the swap, same
|
|
2758
|
+
// belt-and-braces idiom as rerenderAll()/resolveBurst() above.
|
|
2759
|
+
hideTableGrips();
|
|
2760
|
+
hideTableEdgeMenu();
|
|
2761
|
+
cancelTeDrag();
|
|
2762
|
+
// See `suppressTableFocusout`'s own comment (near `currentBurst`'s
|
|
2763
|
+
// declaration) for exactly why this flag is required around this
|
|
2764
|
+
// reassignment. try/finally (review fix): if the assignment itself
|
|
2765
|
+
// throws, the flag must still be cleared — this listener sits at the
|
|
2766
|
+
// TOP of the document-level `focusout` handler, so a latched-true
|
|
2767
|
+
// flag would silently disable blur-commits for EVERY block type
|
|
2768
|
+
// (not just tables) until reload.
|
|
2769
|
+
suppressTableFocusout = true;
|
|
2770
|
+
try {
|
|
2771
|
+
tableEl.innerHTML = state;
|
|
2772
|
+
} finally {
|
|
2773
|
+
suppressTableFocusout = false;
|
|
2774
|
+
}
|
|
2775
|
+
const newCells = tableCellsOf(tableEl);
|
|
2776
|
+
const target = newCells[Math.max(0, Math.min(newCells.length - 1, idx))] || newCells[0];
|
|
2777
|
+
if (target) {
|
|
2778
|
+
burst.activeCellEl = target;
|
|
2779
|
+
selToolbarEditEl = target;
|
|
2780
|
+
target.focus();
|
|
2781
|
+
placeCaretAtEnd(target);
|
|
2782
|
+
}
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
// §10-gap fix (review): same reasoning as burstUndo()'s own guard above
|
|
2786
|
+
// — the resolution switchAwayFrom() is about to run will itself
|
|
2787
|
+
// auto-remove an untouched pristine table insert, so a chained undo()
|
|
2788
|
+
// must be skipped or it cascades one op too far.
|
|
2789
|
+
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
2790
|
+
tableEl.innerHTML === burst.original);
|
|
2791
|
+
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// Ctrl+Y / Ctrl+Shift+Z inside a table burst: symmetric to tableBurstUndo().
|
|
2795
|
+
function tableBurstRedo(cellEl) {
|
|
2796
|
+
const burst = currentBurst;
|
|
2797
|
+
if (!burst || burst.blockType !== 'table' || burst.editEl !== cellEl.closest('table')) return;
|
|
2798
|
+
const tableEl = burst.editEl;
|
|
2799
|
+
const cells = tableCellsOf(tableEl);
|
|
2800
|
+
const idx = cells.indexOf(burst.activeCellEl || cellEl);
|
|
2801
|
+
const state = burst.history.redo();
|
|
2802
|
+
if (state !== null) {
|
|
2803
|
+
// Task 6: same belt-and-braces clear as tableBurstUndo() above.
|
|
2804
|
+
hideTableGrips();
|
|
2805
|
+
hideTableEdgeMenu();
|
|
2806
|
+
cancelTeDrag();
|
|
2807
|
+
// try/finally — same exception-safety reasoning as tableBurstUndo()'s
|
|
2808
|
+
// own comment just above.
|
|
2809
|
+
suppressTableFocusout = true;
|
|
2810
|
+
try {
|
|
2811
|
+
tableEl.innerHTML = state;
|
|
2812
|
+
} finally {
|
|
2813
|
+
suppressTableFocusout = false;
|
|
2814
|
+
}
|
|
2815
|
+
const newCells = tableCellsOf(tableEl);
|
|
2816
|
+
const target = newCells[Math.max(0, Math.min(newCells.length - 1, idx))] || newCells[0];
|
|
2817
|
+
if (target) {
|
|
2818
|
+
burst.activeCellEl = target;
|
|
2819
|
+
selToolbarEditEl = target;
|
|
2820
|
+
target.focus();
|
|
2821
|
+
placeCaretAtEnd(target);
|
|
2822
|
+
}
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
switchAwayFrom().then((ok) => { if (ok) redo(); });
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// The delegated keydown handler's per-keystroke logic for a focused
|
|
2829
|
+
// '.ed-wys-cell' — mirrors handleBurstKeydown() above: Enter is
|
|
2830
|
+
// UNCONDITIONAL <br> insert (never a commit — a table burst has no
|
|
2831
|
+
// Enter-commits gesture at all, only leaving the TABLE or Esc ends it),
|
|
2832
|
+
// Tab/Shift+Tab move the active cell without ending the burst, Escape
|
|
2833
|
+
// reverts, Ctrl+Z/Y drive the burst-local history.
|
|
2834
|
+
function handleTableCellKeydown(e, cellEl) {
|
|
2835
|
+
const tableEl = cellEl.closest('table');
|
|
2836
|
+
if (!currentBurst || currentBurst.blockType !== 'table' || currentBurst.editEl !== tableEl) return;
|
|
2837
|
+
if (e.key === 'Enter') {
|
|
2838
|
+
e.preventDefault();
|
|
2839
|
+
insertBrAtCaret();
|
|
2840
|
+
snapBurstIfActive(tableEl, 'br');
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (e.key === 'Tab') {
|
|
2844
|
+
e.preventDefault();
|
|
2845
|
+
moveActiveTableCell(cellEl, e.shiftKey ? -1 : 1);
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
if (e.key === 'Escape') {
|
|
2849
|
+
e.preventDefault();
|
|
2850
|
+
revertTableBurstAndEnd(cellEl);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
2854
|
+
e.preventDefault();
|
|
2855
|
+
tableBurstUndo(cellEl);
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
|
2859
|
+
e.preventDefault();
|
|
2860
|
+
tableBurstRedo(cellEl);
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// ── Table structure ops (row/col add/del, alignment) ────────────────────
|
|
2866
|
+
// Pure DOM-mutation helpers — UI-independent by design. The Phase-2
|
|
2867
|
+
// floating-toolbar buttons that used to drive these are retired along
|
|
2868
|
+
// with the rest of the click-select bar (Global Constraint: "old
|
|
2869
|
+
// table-op bar buttons already retired in T2's bar removal" — the LAST
|
|
2870
|
+
// bar consumer, tables, is retired here). Task 5 itself only wires
|
|
2871
|
+
// insertRow()/insertColumn() (via
|
|
2872
|
+
// the hover-edge insert bubbles below); deleteRow()/deleteColumn()/
|
|
2873
|
+
// cycleColumnAlign() have no UI surface in THIS task — they stay exactly
|
|
2874
|
+
// as they were (never mutated, never even renamed) specifically so T6's
|
|
2875
|
+
// future edge-menus can wire them up unchanged, per the brief's explicit
|
|
2876
|
+
// "the op FUNCTIONS stay — T6 reuses them from edge menus".
|
|
2877
|
+
//
|
|
2878
|
+
// Column index is read via the real DOM's native `<tr>.cells` (this file
|
|
2879
|
+
// — unlike table-md.js's node-stub-constrained walk — has always used
|
|
2880
|
+
// real DOM APIs). Alignment is read/written on the HEADER cell only for
|
|
2881
|
+
// determining the NEXT state, but applied to every cell (th+td) in the
|
|
2882
|
+
// column: table-md.js's serializeTable() reads alignment from the header
|
|
2883
|
+
// row alone (documented "column-uniform assumption" in that file), so
|
|
2884
|
+
// leaving body cells out of sync here would silently diverge from what
|
|
2885
|
+
// gets committed — see cycleColumnAlign() below.
|
|
2886
|
+
function colIndexOf(cellEl) {
|
|
2887
|
+
return Array.prototype.indexOf.call(cellEl.parentElement.cells, cellEl);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function headerRowOf(tableEl) {
|
|
2891
|
+
return tableEl.tHead ? tableEl.tHead.rows[0] : null;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function bodyRowsOf(tableEl) {
|
|
2895
|
+
const tbody = tableEl.tBodies[0];
|
|
2896
|
+
return tbody ? Array.prototype.slice.call(tbody.rows) : [];
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
// All rows (header first, then body, in document order) — every row/col
|
|
2900
|
+
// structural op below walks this list so header + body cells stay in
|
|
2901
|
+
// lockstep column-for-column.
|
|
2902
|
+
function allRowsOf(tableEl) {
|
|
2903
|
+
const header = headerRowOf(tableEl);
|
|
2904
|
+
return (header ? [header] : []).concat(bodyRowsOf(tableEl));
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Inserts a new, empty body row directly after `afterRow` — or as the
|
|
2908
|
+
// FIRST body row when `afterRow` is the header (or there is no body yet):
|
|
2909
|
+
// there is no "row before the header" to insert after, so a header-
|
|
2910
|
+
// adjacent + boundary falls through to this same first-body-row
|
|
2911
|
+
// placement (this is exactly why the hover-insert bubble below treats the
|
|
2912
|
+
// header's own bottom edge as its own boundary, `afterRowIndex: -1`).
|
|
2913
|
+
function insertRow(tableEl, afterRow) {
|
|
2914
|
+
const colCount = headerRowOf(tableEl) ? headerRowOf(tableEl).cells.length : 0;
|
|
2915
|
+
const tbody = tableEl.tBodies[0];
|
|
2916
|
+
const newRow = document.createElement('tr');
|
|
2917
|
+
for (let i = 0; i < colCount; i++) newRow.appendChild(document.createElement('td'));
|
|
2918
|
+
if (afterRow && afterRow.parentElement === tbody) {
|
|
2919
|
+
afterRow.parentElement.insertBefore(newRow, afterRow.nextSibling);
|
|
2920
|
+
} else if (tbody) {
|
|
2921
|
+
tbody.insertBefore(newRow, tbody.firstChild);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function deleteRow(rowEl) {
|
|
2926
|
+
if (rowEl && rowEl.parentElement) rowEl.parentElement.removeChild(rowEl);
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// Inserts a new, empty cell (th in the header row, td everywhere else) at
|
|
2930
|
+
// `colIndex + 1` in EVERY row — never just the focused row — so the table
|
|
2931
|
+
// stays rectangular (every row the same cell count), a precondition
|
|
2932
|
+
// table-md.js's column-uniform alignment reading (and this file's own
|
|
2933
|
+
// colIndexOf()) both assume.
|
|
2934
|
+
function insertColumn(tableEl, colIndex) {
|
|
2935
|
+
allRowsOf(tableEl).forEach((row) => {
|
|
2936
|
+
const isHeader = row === headerRowOf(tableEl);
|
|
2937
|
+
const cell = document.createElement(isHeader ? 'th' : 'td');
|
|
2938
|
+
const ref = row.cells[colIndex];
|
|
2939
|
+
row.insertBefore(cell, ref ? ref.nextSibling : null);
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function deleteColumn(tableEl, colIndex) {
|
|
2944
|
+
allRowsOf(tableEl).forEach((row) => {
|
|
2945
|
+
const cell = row.cells[colIndex];
|
|
2946
|
+
if (cell) row.removeChild(cell);
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// Mirrors table-md.js's own cellAlign() (that file can't require this one
|
|
2951
|
+
// — node-test-constrained to childNodes/getAttribute only — so the tiny
|
|
2952
|
+
// regex is duplicated rather than shared; keep both in sync if the style
|
|
2953
|
+
// form ever changes).
|
|
2954
|
+
function cellStyleAlign(cell) {
|
|
2955
|
+
const style = cell.getAttribute('style');
|
|
2956
|
+
if (!style) return null;
|
|
2957
|
+
const m = /text-align\s*:\s*(left|right|center)/.exec(style);
|
|
2958
|
+
return m ? m[1] : null;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
const ALIGN_CYCLE = ['left', 'center', 'right'];
|
|
2962
|
+
// Unset/default (no style attribute — GFM's plain `---` separator) is
|
|
2963
|
+
// NOT a cycle stop of its own per the brief ("cycle left→center→right");
|
|
2964
|
+
// indexOf() returning -1 for it lands the FIRST click on 'left' ((-1+1)
|
|
2965
|
+
// % 3 === 0), same as clicking from an explicit 'right'. There is no way
|
|
2966
|
+
// to cycle back OUT to unset once a click has set an explicit alignment.
|
|
2967
|
+
function nextAlign(current) {
|
|
2968
|
+
const idx = ALIGN_CYCLE.indexOf(current);
|
|
2969
|
+
return ALIGN_CYCLE[(idx + 1) % ALIGN_CYCLE.length];
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function cycleColumnAlign(tableEl, colIndex) {
|
|
2973
|
+
const headerRow = headerRowOf(tableEl);
|
|
2974
|
+
const headerCell = headerRow ? headerRow.cells[colIndex] : null;
|
|
2975
|
+
const next = nextAlign(headerCell ? cellStyleAlign(headerCell) : null);
|
|
2976
|
+
allRowsOf(tableEl).forEach((row) => {
|
|
2977
|
+
const cell = row.cells[colIndex];
|
|
2978
|
+
if (cell) cell.setAttribute('style', 'text-align:' + next);
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
// ── Task 5: hover-edge column/row insert bubbles ────────────────────────
|
|
2983
|
+
// A SINGLETON pair of "+" bubble buttons (never one node per boundary —
|
|
2984
|
+
// Global Constraint) built once and repositioned via getBoundingClientRect()
|
|
2985
|
+
// onto whichever boundary (if any) the pointer is currently near, driven by
|
|
2986
|
+
// a single throttled (rAF-coalesced) document `mousemove` listener wired
|
|
2987
|
+
// near the bottom of this file alongside the other delegated listeners.
|
|
2988
|
+
// Column bubble: shown near a table's TOP edge, over the RIGHT edge of one
|
|
2989
|
+
// of its header cells (documented decision: only a boundary AFTER an
|
|
2990
|
+
// existing column is offered — there is no "insert before the first
|
|
2991
|
+
// column" boundary, since insertColumn(tableEl, colIndex)'s own contract
|
|
2992
|
+
// is "insert after colIndex"; see the task-5 report). Row bubble: shown
|
|
2993
|
+
// near a table's LEFT edge, over the BOTTOM edge of the header or any body
|
|
2994
|
+
// row (see insertRow()'s own "header counts as the first boundary" note
|
|
2995
|
+
// just above).
|
|
2996
|
+
const TB_EDGE_PX = 10; // proximity threshold, in CSS px, for "near a boundary"
|
|
2997
|
+
const TB_BUBBLE_SIZE = 18; // must match .ed-tb-insert's CSS width/height
|
|
2998
|
+
|
|
2999
|
+
function buildTableInsertBubble(cls, ariaLabel) {
|
|
3000
|
+
const b = document.createElement('button');
|
|
3001
|
+
b.type = 'button';
|
|
3002
|
+
b.className = 'ed-tb-insert ' + cls;
|
|
3003
|
+
b.textContent = '+';
|
|
3004
|
+
b.setAttribute('aria-label', ariaLabel);
|
|
3005
|
+
b.hidden = true;
|
|
3006
|
+
// Same "keep the burst's focus/selection intact across the click" idiom
|
|
3007
|
+
// as .ed-seltb's own buttons (buildSelToolbar() above) — without this,
|
|
3008
|
+
// the bubble (outside any cell) stealing focus on mousedown would fire a
|
|
3009
|
+
// focusout on the currently-focused cell BEFORE the click handler ever
|
|
3010
|
+
// runs, which (since the bubble sits outside the table) would look like
|
|
3011
|
+
// "focus left the table" and commit the burst before the insert applies.
|
|
3012
|
+
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
3013
|
+
document.body.appendChild(b);
|
|
3014
|
+
return b;
|
|
3015
|
+
}
|
|
3016
|
+
const colInsertBubble = buildTableInsertBubble('ed-tb-insert-col', 'Insert column');
|
|
3017
|
+
const rowInsertBubble = buildTableInsertBubble('ed-tb-insert-row', 'Insert row');
|
|
3018
|
+
|
|
3019
|
+
function hideTableInsertBubbles() {
|
|
3020
|
+
colInsertBubble.hidden = true;
|
|
3021
|
+
rowInsertBubble.hidden = true;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// Ensures a table burst is open and focused on `tableEl` (brief: "auto-
|
|
3025
|
+
// start a burst if none open — decide semantics, document"). Chosen
|
|
3026
|
+
// policy: focuses the table's FIRST cell — same fallback Phase-2's own ✎
|
|
3027
|
+
// button used ("opens the session with the FIRST cell active"). A NO-OP
|
|
3028
|
+
// (touches neither focus nor `switchAwayFrom()`) when `tableEl`'s OWN
|
|
3029
|
+
// burst is ALREADY the open one, so a rapid sequence of bubble clicks on
|
|
3030
|
+
// the same table never re-focuses/re-selects anything mid-sequence.
|
|
3031
|
+
// Resolves whatever ELSE is open first, same precondition every other
|
|
3032
|
+
// open path in this file uses. cell.focus() synchronously starts the
|
|
3033
|
+
// burst via the delegated focusin listener's table-cell branch (see
|
|
3034
|
+
// handleTableCellFocusIn() above) — no separate direct call needed here.
|
|
3035
|
+
//
|
|
3036
|
+
// Final-review Finding 6 (Important): `switchAwayFrom()` here may resolve
|
|
3037
|
+
// a DIFFERENT block's dirty burst (the whole reason this function exists
|
|
3038
|
+
// — a + bubble / edge-menu op / row drop on a table that ISN'T the
|
|
3039
|
+
// currently-focused one still needs whatever else is open committed
|
|
3040
|
+
// first). That commit's rerenderAll() swaps the WHOLE `.content`
|
|
3041
|
+
// subtree, not just the block that was dirty — which detaches `tableEl`
|
|
3042
|
+
// too, even though the table itself was never the thing being resolved.
|
|
3043
|
+
// The old `!document.body.contains(tableEl)` check treated that as
|
|
3044
|
+
// "table's gone" and bailed out, silently discarding the insert/delete/
|
|
3045
|
+
// align/drop the caller was trying to perform. Capture this table's OWN
|
|
3046
|
+
// block id FIRST and, same stale-node recovery the focusin listener uses
|
|
3047
|
+
// above, re-resolve the LIVE table by it when the original reference no
|
|
3048
|
+
// longer resolves — returning that live element (which callers below now
|
|
3049
|
+
// use in place of their own now-possibly-stale `tableEl`) instead of a
|
|
3050
|
+
// bare boolean, so a caller can never accidentally keep operating on the
|
|
3051
|
+
// detached node it started with.
|
|
3052
|
+
async function ensureTableBurstOpen(tableEl) {
|
|
3053
|
+
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
|
|
3054
|
+
const blockEl = tableEl.closest('.ed-block');
|
|
3055
|
+
const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
3056
|
+
const ok = await switchAwayFrom();
|
|
3057
|
+
if (!ok) return null;
|
|
3058
|
+
let liveTableEl = tableEl;
|
|
3059
|
+
if (!document.body.contains(tableEl)) {
|
|
3060
|
+
const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
|
|
3061
|
+
liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
3062
|
+
if (!liveTableEl || !liveTableEl.classList.contains('ed-wys-table')) return null;
|
|
3063
|
+
}
|
|
3064
|
+
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
|
|
3065
|
+
const cell = tableCellsOf(liveTableEl)[0];
|
|
3066
|
+
if (!cell) return null;
|
|
3067
|
+
cell.focus();
|
|
3068
|
+
return (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl)
|
|
3069
|
+
? liveTableEl : null;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// A cell inserted by insertColumn()/insertRow() (reused unchanged from
|
|
3073
|
+
// Phase 2 — see the section comment above) is plain markup with no
|
|
3074
|
+
// contenteditable/class attributes of its own; the hover-insert click
|
|
3075
|
+
// handlers below call this immediately after each insert so the new
|
|
3076
|
+
// cell is armed exactly like every other cell in an already-armed table
|
|
3077
|
+
// (idempotent: only touches cells that aren't already armed, so it's
|
|
3078
|
+
// safe to call unconditionally over the whole table rather than tracking
|
|
3079
|
+
// exactly which cells an insert just created).
|
|
3080
|
+
function armNewTableCells(tableEl) {
|
|
3081
|
+
tableCellsOf(tableEl).forEach((cell) => {
|
|
3082
|
+
if (!cell.classList.contains('ed-wys-cell')) {
|
|
3083
|
+
cell.setAttribute('contenteditable', 'true');
|
|
3084
|
+
cell.classList.add('ed-wys-cell');
|
|
3085
|
+
}
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
async function onColInsertBubbleClick() {
|
|
3090
|
+
const tableEl = hoveredInsertTableEl;
|
|
3091
|
+
const colIndex = Number(colInsertBubble.dataset.colIndex);
|
|
3092
|
+
if (!tableEl || Number.isNaN(colIndex)) return;
|
|
3093
|
+
// Final-review Finding 6: use the LIVE table ensureTableBurstOpen()
|
|
3094
|
+
// resolves to (which may differ from `tableEl` if a dirty burst
|
|
3095
|
+
// elsewhere just committed and swapped `.content` out from under this
|
|
3096
|
+
// click) — never the possibly-now-detached `tableEl` itself.
|
|
3097
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3098
|
+
if (!liveTableEl) return;
|
|
3099
|
+
insertColumn(liveTableEl, colIndex);
|
|
3100
|
+
armNewTableCells(liveTableEl);
|
|
3101
|
+
currentBurst.history.snap('insert-col');
|
|
3102
|
+
hideTableInsertBubbles(); // the boundary geometry just changed; wait for the next mousemove
|
|
3103
|
+
hideTableGrips(); // same reasoning — a shifted column index would otherwise stay stale
|
|
3104
|
+
}
|
|
3105
|
+
colInsertBubble.addEventListener('click', (e) => { e.stopPropagation(); onColInsertBubbleClick(); });
|
|
3106
|
+
|
|
3107
|
+
async function onRowInsertBubbleClick() {
|
|
3108
|
+
const tableEl = hoveredInsertTableEl;
|
|
3109
|
+
const afterRowIndex = Number(rowInsertBubble.dataset.afterRowIndex); // -1 -> header (first body row)
|
|
3110
|
+
if (!tableEl || Number.isNaN(afterRowIndex)) return;
|
|
3111
|
+
// Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
|
|
3112
|
+
// above — `afterRowIndex` is a plain integer so it stays valid across
|
|
3113
|
+
// the swap unchanged, only the table element reference needs re-resolving.
|
|
3114
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3115
|
+
if (!liveTableEl) return;
|
|
3116
|
+
const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
|
|
3117
|
+
insertRow(liveTableEl, afterRow);
|
|
3118
|
+
armNewTableCells(liveTableEl);
|
|
3119
|
+
currentBurst.history.snap('insert-row');
|
|
3120
|
+
hideTableInsertBubbles();
|
|
3121
|
+
hideTableGrips(); // same reasoning as onColInsertBubbleClick() above
|
|
3122
|
+
}
|
|
3123
|
+
rowInsertBubble.addEventListener('click', (e) => { e.stopPropagation(); onRowInsertBubbleClick(); });
|
|
3124
|
+
|
|
3125
|
+
// The table the bubbles are currently positioned against, if any — set by
|
|
3126
|
+
// updateTableInsertBubbles() below, read back by the two click handlers
|
|
3127
|
+
// above (dataset carries WHICH boundary; this carries WHICH table).
|
|
3128
|
+
let hoveredInsertTableEl = null;
|
|
3129
|
+
|
|
3130
|
+
// Recomputes bubble visibility/position from the latest throttled pointer
|
|
3131
|
+
// coordinates — called from the mousemove listener wired near the bottom
|
|
3132
|
+
// of this file. `target` is whatever element was directly under the
|
|
3133
|
+
// pointer (Event#target) at those coordinates.
|
|
3134
|
+
function updateTableInsertBubbles(x, y, target) {
|
|
3135
|
+
const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
|
|
3136
|
+
const tableEl = blockEl ? blockContentEl(blockEl) : null;
|
|
3137
|
+
if (!tableEl || !tableEl.classList.contains('ed-wys-table')) {
|
|
3138
|
+
hideTableInsertBubbles();
|
|
3139
|
+
hoveredInsertTableEl = null;
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
hoveredInsertTableEl = tableEl;
|
|
3143
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
3144
|
+
const half = TB_BUBBLE_SIZE / 2;
|
|
3145
|
+
|
|
3146
|
+
let colShown = false;
|
|
3147
|
+
const headerRow = headerRowOf(tableEl);
|
|
3148
|
+
if (headerRow && Math.abs(y - tableRect.top) <= TB_EDGE_PX) {
|
|
3149
|
+
const headerCells = Array.prototype.slice.call(headerRow.cells);
|
|
3150
|
+
for (let i = 0; i < headerCells.length; i++) {
|
|
3151
|
+
const r = headerCells[i].getBoundingClientRect();
|
|
3152
|
+
if (Math.abs(x - r.right) <= TB_EDGE_PX) {
|
|
3153
|
+
colInsertBubble.dataset.colIndex = String(i);
|
|
3154
|
+
colInsertBubble.style.left = (r.right - half) + 'px';
|
|
3155
|
+
colInsertBubble.style.top = (tableRect.top - half) + 'px';
|
|
3156
|
+
colInsertBubble.hidden = false;
|
|
3157
|
+
colShown = true;
|
|
3158
|
+
break;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
if (!colShown) colInsertBubble.hidden = true;
|
|
3163
|
+
|
|
3164
|
+
let rowShown = false;
|
|
3165
|
+
if (Math.abs(x - tableRect.left) <= TB_EDGE_PX) {
|
|
3166
|
+
const boundaries = [];
|
|
3167
|
+
if (headerRow) boundaries.push({ y: headerRow.getBoundingClientRect().bottom, afterRowIndex: -1 });
|
|
3168
|
+
bodyRowsOf(tableEl).forEach((row, i) => {
|
|
3169
|
+
boundaries.push({ y: row.getBoundingClientRect().bottom, afterRowIndex: i });
|
|
3170
|
+
});
|
|
3171
|
+
for (let i = 0; i < boundaries.length; i++) {
|
|
3172
|
+
if (Math.abs(y - boundaries[i].y) <= TB_EDGE_PX) {
|
|
3173
|
+
rowInsertBubble.dataset.afterRowIndex = String(boundaries[i].afterRowIndex);
|
|
3174
|
+
rowInsertBubble.style.left = (tableRect.left - half) + 'px';
|
|
3175
|
+
rowInsertBubble.style.top = (boundaries[i].y - half) + 'px';
|
|
3176
|
+
rowInsertBubble.hidden = false;
|
|
3177
|
+
rowShown = true;
|
|
3178
|
+
break;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
if (!rowShown) rowInsertBubble.hidden = true;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// ── Task 6: table edge-click menus (delete/align) + row drag-reorder ────
|
|
3186
|
+
// Clicking a column's grip handle (the horizontal 6-dot affordance shown
|
|
3187
|
+
// just above the column while hovering it — see the "Notion-style grip
|
|
3188
|
+
// handles" section below) selects the column: every th/td in it gets
|
|
3189
|
+
// '.ed-te-hl' and a floating menu (delete / align-cycle) appears. Clicking
|
|
3190
|
+
// a row's grip handle (the vertical 6-dot affordance shown just left of
|
|
3191
|
+
// the row while hovering it) selects the row the same way, with a
|
|
3192
|
+
// delete-only menu. User-acceptance feedback on the ORIGINAL design (an
|
|
3193
|
+
// invisible TE_EDGE_PX=8 proximity zone hugging the table's raw top/left
|
|
3194
|
+
// pixel edge, with no visible affordance at all) was that it was
|
|
3195
|
+
// unusably small — pixel-hunting a click target with no visual cue. The
|
|
3196
|
+
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
3197
|
+
// user can actually see and aim for. Both grips are OUTSIDE the table
|
|
3198
|
+
// (never inside a contenteditable cell), so — unlike the old zones, which
|
|
3199
|
+
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
3200
|
+
// delegated `pointerdown` listener below to preventDefault() there to
|
|
3201
|
+
// stop native caret placement from stealing the click — a grip's own
|
|
3202
|
+
// buildTableGrip()-installed `mousedown` preventDefault() is what keeps
|
|
3203
|
+
// focus put now (same "keep focus put" idiom buildTableInsertBubble()
|
|
3204
|
+
// documents for the hover-insert bubbles above).
|
|
3205
|
+
//
|
|
3206
|
+
// Row drag starts from the SAME row grip (body rows only — the header
|
|
3207
|
+
// <tr> is never draggable, per the brief, and never gets a grip at all):
|
|
3208
|
+
// after a small movement threshold (distinguishing "click to open the
|
|
3209
|
+
// menu" from "press-and-drag"), a drop-indicator line tracks the pointer
|
|
3210
|
+
// between body rows; releasing performs the reorder via a plain
|
|
3211
|
+
// `insertBefore()` on the SAME <tr> node (never a clone/innerHTML-
|
|
3212
|
+
// replace). The DOM's "insert" algorithm reparents a node in one
|
|
3213
|
+
// synchronous step without an observable disconnected state, so — unlike
|
|
3214
|
+
// tableBurstUndo()/tableBurstRedo()'s innerHTML-snapshot restore just
|
|
3215
|
+
// above — moving the dragged row this way never blurs it even if it
|
|
3216
|
+
// happened to contain the active cell, so this needs no
|
|
3217
|
+
// suppressTableFocusout guard. The menu's delete ops (which DO remove
|
|
3218
|
+
// nodes) sidestep the same hazard a different way — see
|
|
3219
|
+
// refocusAwayFromColumn()/refocusAwayFromRow() below.
|
|
3220
|
+
//
|
|
3221
|
+
// Both the menu (delete/align) and the drag's DOM move are burst
|
|
3222
|
+
// mutations: ensureTableBurstOpen() (Task 5, above) auto-starts a burst
|
|
3223
|
+
// on this table if none is open yet, then the op mutates the live DOM and
|
|
3224
|
+
// calls currentBurst.history.snap() — committed on table-leave like any
|
|
3225
|
+
// other table edit. Refusal banners reuse the exact wording the retired
|
|
3226
|
+
// Phase-2 click-select edit toolbar used for these same three guards (see
|
|
3227
|
+
// commit 0661cde, now dead code with zero call sites — this task is what
|
|
3228
|
+
// revives deleteRow()/deleteColumn()/cycleColumnAlign() unchanged, per
|
|
3229
|
+
// the brief's explicit "wire, don't rewrite"). This grip-based revision
|
|
3230
|
+
// keeps every one of those downstream primitives (menu building, delete/
|
|
3231
|
+
// align ops, drag-drop pointer machinery) UNCHANGED — only the "what
|
|
3232
|
+
// counts as a hit" question moved from raw geometry (hitTestEdgeZone(),
|
|
3233
|
+
// now retired) to hitTestGrip() (defined in the grip section below).
|
|
3234
|
+
const TE_DRAG_THRESHOLD_PX = 5; // pointer movement before a press becomes a drag
|
|
3235
|
+
const TE_MENU_GAP_PX = 6;
|
|
3236
|
+
|
|
3237
|
+
// The column/row currently selected by the edge menu (or all-null when
|
|
3238
|
+
// closed) — read by the menu's own button handlers below, cleared by
|
|
3239
|
+
// hideTableEdgeMenu().
|
|
3240
|
+
let teMenuKind = null; // 'col' | 'row' | null
|
|
3241
|
+
let teMenuTableEl = null;
|
|
3242
|
+
let teMenuColIndex = null; // meaningful only when teMenuKind === 'col'
|
|
3243
|
+
let teMenuRowEl = null; // meaningful only when teMenuKind === 'row'
|
|
3244
|
+
// Elements currently wearing the '.ed-te-hl' highlight class — tracked so
|
|
3245
|
+
// clearEdgeHighlight() can strip it again without re-deriving the
|
|
3246
|
+
// (possibly now-stale, post-delete) column/row it came from.
|
|
3247
|
+
let teHighlightEls = [];
|
|
3248
|
+
|
|
3249
|
+
function clearEdgeHighlight() {
|
|
3250
|
+
teHighlightEls.forEach((el) => el.classList.remove('ed-te-hl'));
|
|
3251
|
+
teHighlightEls = [];
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function highlightColumn(tableEl, colIndex) {
|
|
3255
|
+
clearEdgeHighlight();
|
|
3256
|
+
allRowsOf(tableEl).forEach((row) => {
|
|
3257
|
+
const cell = row.cells[colIndex];
|
|
3258
|
+
if (cell) { cell.classList.add('ed-te-hl'); teHighlightEls.push(cell); }
|
|
3259
|
+
});
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
function highlightRow(rowEl) {
|
|
3263
|
+
clearEdgeHighlight();
|
|
3264
|
+
rowEl.classList.add('ed-te-hl');
|
|
3265
|
+
teHighlightEls.push(rowEl);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
function hideTableEdgeMenu() {
|
|
3269
|
+
teEdgeMenu.hidden = true;
|
|
3270
|
+
clearEdgeHighlight();
|
|
3271
|
+
teMenuKind = null;
|
|
3272
|
+
teMenuTableEl = null;
|
|
3273
|
+
teMenuColIndex = null;
|
|
3274
|
+
teMenuRowEl = null;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// A single singleton floating menu (never one per column/row — same
|
|
3278
|
+
// Global Constraint the hover-insert bubbles above follow) whose two
|
|
3279
|
+
// buttons are relabeled/shown-or-hidden per teMenuKind by
|
|
3280
|
+
// showColumnMenu()/showRowMenu() below, rather than rebuilding it.
|
|
3281
|
+
function buildTeMenuButton(cls, label, onClick) {
|
|
3282
|
+
const b = document.createElement('button');
|
|
3283
|
+
b.type = 'button';
|
|
3284
|
+
b.className = 'ed-te-menu-btn ' + cls;
|
|
3285
|
+
b.textContent = label;
|
|
3286
|
+
// Same "keep the burst's focus intact across the click" idiom as
|
|
3287
|
+
// buildTableInsertBubble()'s own buttons above — see the section
|
|
3288
|
+
// comment for why this matters (and why the focusout handler below
|
|
3289
|
+
// ALSO excludes '.ed-te-menu' as belt-and-braces alongside this).
|
|
3290
|
+
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
3291
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
3292
|
+
return b;
|
|
3293
|
+
}
|
|
3294
|
+
const teEdgeMenu = document.createElement('div');
|
|
3295
|
+
teEdgeMenu.className = 'ed-te-menu';
|
|
3296
|
+
teEdgeMenu.hidden = true;
|
|
3297
|
+
const teDeleteBtn = buildTeMenuButton('ed-te-menu-delete', '', async () => {
|
|
3298
|
+
if (teMenuKind === 'col') await runDeleteColumn();
|
|
3299
|
+
else if (teMenuKind === 'row') await runDeleteRow();
|
|
3300
|
+
});
|
|
3301
|
+
const teAlignBtn = buildTeMenuButton('ed-te-menu-align', '對齊', async () => { await runCycleAlign(); });
|
|
3302
|
+
teEdgeMenu.appendChild(teDeleteBtn);
|
|
3303
|
+
teEdgeMenu.appendChild(teAlignBtn);
|
|
3304
|
+
document.body.appendChild(teEdgeMenu);
|
|
3305
|
+
|
|
3306
|
+
// Shows the menu at `(left, top)`, then (now that it's visible and
|
|
3307
|
+
// measurable) lets the caller shift it by its own real offsetWidth/
|
|
3308
|
+
// offsetHeight — a two-step "show, then measure, then reposition" dance
|
|
3309
|
+
// that avoids hardcoding the menu's size (which differs between the
|
|
3310
|
+
// column form — two buttons — and the row form — one).
|
|
3311
|
+
function positionTeMenu(left, top) {
|
|
3312
|
+
teEdgeMenu.style.left = left + 'px';
|
|
3313
|
+
teEdgeMenu.style.top = Math.max(0, top) + 'px';
|
|
3314
|
+
teEdgeMenu.hidden = false;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
function showColumnMenu(tableEl, colIndex) {
|
|
3318
|
+
if (teMenuKind === 'col' && teMenuTableEl === tableEl && teMenuColIndex === colIndex) {
|
|
3319
|
+
hideTableEdgeMenu(); // re-clicking the same column's edge toggles the menu closed
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
teMenuKind = 'col';
|
|
3323
|
+
teMenuTableEl = tableEl;
|
|
3324
|
+
teMenuColIndex = colIndex;
|
|
3325
|
+
teMenuRowEl = null;
|
|
3326
|
+
teDeleteBtn.textContent = '刪除欄';
|
|
3327
|
+
teAlignBtn.hidden = false;
|
|
3328
|
+
highlightColumn(tableEl, colIndex);
|
|
3329
|
+
const headerRow = headerRowOf(tableEl);
|
|
3330
|
+
const cell = headerRow ? headerRow.cells[colIndex] : null;
|
|
3331
|
+
const r = (cell || tableEl).getBoundingClientRect();
|
|
3332
|
+
positionTeMenu(r.left, r.top);
|
|
3333
|
+
teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
function showRowMenu(tableEl, rowEl) {
|
|
3337
|
+
if (teMenuKind === 'row' && teMenuTableEl === tableEl && teMenuRowEl === rowEl) {
|
|
3338
|
+
hideTableEdgeMenu(); // re-clicking the same row's edge toggles the menu closed
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
teMenuKind = 'row';
|
|
3342
|
+
teMenuTableEl = tableEl;
|
|
3343
|
+
teMenuRowEl = rowEl;
|
|
3344
|
+
teMenuColIndex = null;
|
|
3345
|
+
teDeleteBtn.textContent = '刪除列';
|
|
3346
|
+
teAlignBtn.hidden = true;
|
|
3347
|
+
highlightRow(rowEl);
|
|
3348
|
+
const r = rowEl.getBoundingClientRect();
|
|
3349
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
3350
|
+
positionTeMenu(tableRect.left, r.top);
|
|
3351
|
+
teEdgeMenu.style.left = (tableRect.left - teEdgeMenu.offsetWidth - TE_MENU_GAP_PX) + 'px';
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// Moves `currentBurst.activeCellEl` to a cell that will SURVIVE deleting
|
|
3355
|
+
// `colIndex` — called BEFORE deleteColumn() so the removal never touches
|
|
3356
|
+
// the currently-focused node in the first place. A plain .focus() call
|
|
3357
|
+
// here fires a focusout+focusin pair that both resolve to cells inside
|
|
3358
|
+
// the SAME table — the delegated focusout handler's `stillInTable` check
|
|
3359
|
+
// (relatedTarget still inside tableEl) already treats that as a normal
|
|
3360
|
+
// in-burst cell move, not "left the table" — so this needs no
|
|
3361
|
+
// suppressTableFocusout: unlike tableBurstUndo()/Redo()'s innerHTML-
|
|
3362
|
+
// snapshot restore, nothing here ever detaches the currently-focused node
|
|
3363
|
+
// WHILE it's still focused.
|
|
3364
|
+
function refocusAwayFromColumn(tableEl, colIndex) {
|
|
3365
|
+
const burst = currentBurst;
|
|
3366
|
+
if (!burst || !burst.activeCellEl || !document.body.contains(burst.activeCellEl)) return;
|
|
3367
|
+
if (colIndexOf(burst.activeCellEl) !== colIndex) return;
|
|
3368
|
+
const row = burst.activeCellEl.parentElement;
|
|
3369
|
+
const alt = row.cells[colIndex === 0 ? 1 : 0];
|
|
3370
|
+
if (alt) { alt.focus(); placeCaretAtEnd(alt); }
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// Mirrors refocusAwayFromColumn() for a doomed ROW: picks the previous
|
|
3374
|
+
// row (or the next one, for row index 0) at the same column index — always
|
|
3375
|
+
// safe to assume one exists, since deleteRow() is only ever reached after
|
|
3376
|
+
// the header/last-body-row refusal checks in runDeleteRow() below have
|
|
3377
|
+
// already passed.
|
|
3378
|
+
function refocusAwayFromRow(tableEl, rowEl) {
|
|
3379
|
+
const burst = currentBurst;
|
|
3380
|
+
if (!burst || !burst.activeCellEl || !document.body.contains(burst.activeCellEl)) return;
|
|
3381
|
+
if (burst.activeCellEl.parentElement !== rowEl) return;
|
|
3382
|
+
const rows = allRowsOf(tableEl);
|
|
3383
|
+
const idx = rows.indexOf(rowEl);
|
|
3384
|
+
const altRow = idx <= 0 ? rows[1] : rows[idx - 1];
|
|
3385
|
+
if (!altRow) return;
|
|
3386
|
+
const colIdx = colIndexOf(burst.activeCellEl);
|
|
3387
|
+
const alt = altRow.cells[colIdx] || altRow.cells[0];
|
|
3388
|
+
if (alt) { alt.focus(); placeCaretAtEnd(alt); }
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
async function runDeleteColumn() {
|
|
3392
|
+
const tableEl = teMenuTableEl;
|
|
3393
|
+
const colIndex = teMenuColIndex;
|
|
3394
|
+
if (!tableEl || colIndex == null) return;
|
|
3395
|
+
// Final-review Finding 6: use the LIVE table (a dirty burst on a
|
|
3396
|
+
// DIFFERENT block may have just committed inside ensureTableBurstOpen(),
|
|
3397
|
+
// swapping `.content` and detaching `tableEl`) — column index is a
|
|
3398
|
+
// plain integer, stable across that swap, so only the element itself
|
|
3399
|
+
// needs re-resolving.
|
|
3400
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3401
|
+
if (!liveTableEl) return;
|
|
3402
|
+
const headerRow = headerRowOf(liveTableEl);
|
|
3403
|
+
if (!headerRow || headerRow.cells.length <= 1) {
|
|
3404
|
+
showBanner('無法刪除最後一欄', null, null);
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
refocusAwayFromColumn(liveTableEl, colIndex);
|
|
3408
|
+
deleteColumn(liveTableEl, colIndex);
|
|
3409
|
+
currentBurst.history.snap('delete-col');
|
|
3410
|
+
hideTableEdgeMenu();
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
async function runDeleteRow() {
|
|
3414
|
+
const tableEl = teMenuTableEl;
|
|
3415
|
+
const rowEl = teMenuRowEl;
|
|
3416
|
+
if (!tableEl || !rowEl) return;
|
|
3417
|
+
// Final-review Finding 6: `rowEl` is a DOM node, not an index — capture
|
|
3418
|
+
// its ORDINAL position in the (still-live-at-this-point) table BEFORE
|
|
3419
|
+
// ensureTableBurstOpen() can possibly commit a different block's dirty
|
|
3420
|
+
// burst and swap `.content` out from under it, then re-locate the row
|
|
3421
|
+
// at that same position in the LIVE table afterward. A plain
|
|
3422
|
+
// `document.body.contains(rowEl)` check can't recover from this the
|
|
3423
|
+
// way it can for a stable index — the row must be re-found by where it
|
|
3424
|
+
// WAS, not by its (now-stale) identity.
|
|
3425
|
+
const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
|
|
3426
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3427
|
+
if (!liveTableEl) return;
|
|
3428
|
+
const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
|
|
3429
|
+
if (!liveRowEl) return;
|
|
3430
|
+
if (liveRowEl === headerRowOf(liveTableEl)) {
|
|
3431
|
+
showBanner('無法刪除標題列', null, null);
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
if (bodyRowsOf(liveTableEl).length <= 1) {
|
|
3435
|
+
showBanner('無法刪除最後一列', null, null);
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
refocusAwayFromRow(liveTableEl, liveRowEl);
|
|
3439
|
+
deleteRow(liveRowEl);
|
|
3440
|
+
currentBurst.history.snap('delete-row');
|
|
3441
|
+
hideTableEdgeMenu();
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
async function runCycleAlign() {
|
|
3445
|
+
const tableEl = teMenuTableEl;
|
|
3446
|
+
const colIndex = teMenuColIndex;
|
|
3447
|
+
if (!tableEl || colIndex == null) return;
|
|
3448
|
+
// Final-review Finding 6: same live-table swap as runDeleteColumn() above.
|
|
3449
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3450
|
+
if (!liveTableEl) return;
|
|
3451
|
+
cycleColumnAlign(liveTableEl, colIndex);
|
|
3452
|
+
currentBurst.history.snap('align-col');
|
|
3453
|
+
// Stays open (unlike delete): repeated clicks keep cycling. The
|
|
3454
|
+
// column's cells are the SAME nodes (cycleColumnAlign() only touches
|
|
3455
|
+
// the `style` attribute) so the existing highlight is still valid —
|
|
3456
|
+
// nothing to reposition/rehighlight.
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
// ── Notion-style row/column grip handles ─────────────────────────────
|
|
3460
|
+
// Two singleton overlay elements — same "one shared node, repositioned
|
|
3461
|
+
// via getBoundingClientRect(), never one per row/column" Global Constraint
|
|
3462
|
+
// the hover-insert bubbles above follow. `rowGrip` is a vertical 6-dot
|
|
3463
|
+
// handle shown just LEFT of whichever BODY row (never the header — it
|
|
3464
|
+
// isn't deletable/draggable, so it never gets one) the pointer is
|
|
3465
|
+
// currently hovering any cell of; `colGrip` is a horizontal 6-dot handle
|
|
3466
|
+
// shown just ABOVE whichever column the pointer is hovering (every
|
|
3467
|
+
// column, header included — the column menu's delete/align both apply to
|
|
3468
|
+
// header cells too). Built once by buildTableGrip() below and driven by
|
|
3469
|
+
// updateTableEdgeGrips(), called from the SAME rAF-throttled mousemove
|
|
3470
|
+
// listener (wired near the bottom of this file) that already drives
|
|
3471
|
+
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
3472
|
+
// contract this reuses.
|
|
3473
|
+
// Review fix (P0-a): both grips sit ON the table border — the grip's
|
|
3474
|
+
// centerline coincides with the table's left/top edge, so its hit rect
|
|
3475
|
+
// straddles the border by ~half its own width/height on each side.
|
|
3476
|
+
// This means the grip's hit rect DOES overlap the insert bubble's hit
|
|
3477
|
+
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
3478
|
+
// own axis). Non-intersection via rect separation is no longer possible
|
|
3479
|
+
// or required. Instead, "insert-bubble click is never eaten by the grip"
|
|
3480
|
+
// is maintained by z-index ordering: .ed-te-grip-row/.ed-te-grip-col
|
|
3481
|
+
// carry z-index:7 (these rulesets come after .ed-te-grip's z-index:9 in
|
|
3482
|
+
// source-order with equal specificity, so the later value wins), which
|
|
3483
|
+
// is below the bubble's z-index:8. The browser's hit-test therefore
|
|
3484
|
+
// awards a click at the overlap corner to the BUBBLE, not the grip,
|
|
3485
|
+
// even though their rects overlap.
|
|
3486
|
+
// test/editor-client-runtime.test.js's "table grip/bubble click priority"
|
|
3487
|
+
// scenario asserts this via document.elementFromPoint() at the exact
|
|
3488
|
+
// reported overlap corner AND verifies the z-index ordering directly —
|
|
3489
|
+
// re-verify it if either the grip's or bubble's z-index ever changes.
|
|
3490
|
+
|
|
3491
|
+
function buildTableGrip(cls, ariaLabel) {
|
|
3492
|
+
const b = document.createElement('button');
|
|
3493
|
+
b.type = 'button';
|
|
3494
|
+
b.className = 'ed-te-grip ' + cls;
|
|
3495
|
+
b.setAttribute('aria-label', ariaLabel);
|
|
3496
|
+
for (let i = 0; i < 6; i++) {
|
|
3497
|
+
const dot = document.createElement('span');
|
|
3498
|
+
dot.className = 'ed-te-grip-dot';
|
|
3499
|
+
b.appendChild(dot);
|
|
3500
|
+
}
|
|
3501
|
+
b.hidden = true;
|
|
3502
|
+
// Same "keep the burst's focus/selection intact across the click" idiom
|
|
3503
|
+
// buildTableInsertBubble() above documents — without this, the grip
|
|
3504
|
+
// (outside the table) stealing focus on mousedown would fire a focusout
|
|
3505
|
+
// on the currently-focused cell BEFORE this gesture's own `pointerdown`
|
|
3506
|
+
// handler below even runs.
|
|
3507
|
+
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
3508
|
+
document.body.appendChild(b);
|
|
3509
|
+
return b;
|
|
3510
|
+
}
|
|
3511
|
+
const rowGrip = buildTableGrip('ed-te-grip-row', '列選項 / 拖曳排序');
|
|
3512
|
+
const colGrip = buildTableGrip('ed-te-grip-col', '欄選項');
|
|
3513
|
+
|
|
3514
|
+
// Which table/row/column the two grips are CURRENTLY pinned to — updated
|
|
3515
|
+
// by updateTableEdgeGrips() below, read back by hitTestGrip() at
|
|
3516
|
+
// pointerdown time. `gripRowEl` is a live DOM reference (read
|
|
3517
|
+
// synchronously, at the moment of the click/press that follows the hover
|
|
3518
|
+
// that set it — no staleness window); `gripColIndex` is a plain ordinal
|
|
3519
|
+
// integer, same "stable across a live-table swap" reasoning
|
|
3520
|
+
// runDeleteColumn() etc. above already rely on for column indices.
|
|
3521
|
+
let gripRowTableEl = null;
|
|
3522
|
+
let gripRowEl = null;
|
|
3523
|
+
let gripColTableEl = null;
|
|
3524
|
+
let gripColIndex = null;
|
|
3525
|
+
|
|
3526
|
+
function hideTableGrips() {
|
|
3527
|
+
rowGrip.hidden = true;
|
|
3528
|
+
colGrip.hidden = true;
|
|
3529
|
+
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
3530
|
+
gripRowTableEl = null;
|
|
3531
|
+
gripRowEl = null;
|
|
3532
|
+
gripColTableEl = null;
|
|
3533
|
+
gripColIndex = null;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
// Bug fix (user acceptance): grips are visible on hover but were
|
|
3537
|
+
// UNREACHABLE by a real pointer. Root cause — a pointer travelling from
|
|
3538
|
+
// inside a cell toward a grip necessarily crosses a ~10px corridor
|
|
3539
|
+
// OUTSIDE the table's border on the way (the grip's own left/top half,
|
|
3540
|
+
// since the grip now straddles the border — P0-a border-centred
|
|
3541
|
+
// geometry). The naive hit test below ("on a cell, or hide") hid the
|
|
3542
|
+
// grip the instant the pointer left the table/cell — BEFORE it ever
|
|
3543
|
+
// reached the grip — so only a teleporting click (every existing test
|
|
3544
|
+
// used pressReleaseAt()/gripCenter(), which jump straight to the grip's
|
|
3545
|
+
// own coordinates) could ever land on it; a real mouse gesture could not.
|
|
3546
|
+
//
|
|
3547
|
+
// Review fix (Important, first pass over-permissive): the first version of
|
|
3548
|
+
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
3549
|
+
// table's rect expanded by the grip's own footprint — i.e. along the
|
|
3550
|
+
// table's FULL height/width, not just near the row/column the grip is
|
|
3551
|
+
// actually anchored to. On a tall table, hovering row 1 then moving the
|
|
3552
|
+
// pointer to the left margin at row 10's height (far below row 1's grip,
|
|
3553
|
+
// reviewer live-reproduced) kept row 1's grip visible at its now-stale
|
|
3554
|
+
// position instead of hiding it. Fixed by gating the keep-zone on the
|
|
3555
|
+
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
3556
|
+
// pointInColGripZone() below) instead of the whole table: the union of
|
|
3557
|
+
// (the grip's own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel
|
|
3558
|
+
// rounding at the very corner) and (the straight corridor between the
|
|
3559
|
+
// grip's left/top edge and the table's left/top border — for the row
|
|
3560
|
+
// grip, x between the grip's own left edge and the table's left edge,
|
|
3561
|
+
// y clamped to the ANCHOR ROW's own vertical extent, padded; the column
|
|
3562
|
+
// grip is symmetric against its anchor column's horizontal extent).
|
|
3563
|
+
// A pointer at the SAME x/y-corridor position but outside the anchor
|
|
3564
|
+
// row/column's own extent is a genuine exit and still hides the grip via
|
|
3565
|
+
// hideTableGrips(), same as before. Neither fix touches either grip's
|
|
3566
|
+
// position/size or z-index, so the click-priority guarantee (bubble
|
|
3567
|
+
// z-index:8 > grip z-index:7 — see the comment above buildTableGrip())
|
|
3568
|
+
// is unaffected — this only changes how long an already-shown grip STAYS
|
|
3569
|
+
// visible, never where it sits. See
|
|
3570
|
+
// test/editor-client-runtime.test.js's "grip reachability by a REAL
|
|
3571
|
+
// (non-teleporting) pointer" scenario (positive case) and "grip hover
|
|
3572
|
+
// corridor is anchored to its own row, not the whole table" (the
|
|
3573
|
+
// reviewer's negative-case repro).
|
|
3574
|
+
const TE_GRIP_ZONE_PAD_PX = 4; // sub-pixel-rounding slack around a grip's own rect / its anchor row/column extent
|
|
3575
|
+
|
|
3576
|
+
function pointInPaddedRect(x, y, rect, pad) {
|
|
3577
|
+
return x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function pointInRowGripZone(x, y) {
|
|
3581
|
+
if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
|
|
3582
|
+
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
3583
|
+
const gr = rowGrip.getBoundingClientRect();
|
|
3584
|
+
if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
|
|
3585
|
+
const tableRect = gripRowTableEl.getBoundingClientRect();
|
|
3586
|
+
const rowRect = gripRowEl.getBoundingClientRect();
|
|
3587
|
+
return x >= gr.left && x <= tableRect.left &&
|
|
3588
|
+
y >= rowRect.top - TE_GRIP_ZONE_PAD_PX && y <= rowRect.bottom + TE_GRIP_ZONE_PAD_PX;
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
function pointInColGripZone(x, y) {
|
|
3592
|
+
if (colGrip.hidden || !gripColTableEl || gripColIndex == null ||
|
|
3593
|
+
!document.body.contains(gripColTableEl)) return false;
|
|
3594
|
+
const gc = colGrip.getBoundingClientRect();
|
|
3595
|
+
if (pointInPaddedRect(x, y, gc, TE_GRIP_ZONE_PAD_PX)) return true;
|
|
3596
|
+
// Same header-cell-first, hovered-cell-fallback basis
|
|
3597
|
+
// updateTableEdgeGrips() itself positions the column grip against — see
|
|
3598
|
+
// its own comment for why (every WYSIWYG-armed table has a header in
|
|
3599
|
+
// practice; defensive only).
|
|
3600
|
+
const headerRow = headerRowOf(gripColTableEl);
|
|
3601
|
+
const anchorCell = headerRow ? headerRow.cells[gripColIndex] : null;
|
|
3602
|
+
if (!anchorCell) return false;
|
|
3603
|
+
const tableRect = gripColTableEl.getBoundingClientRect();
|
|
3604
|
+
const cellRect = anchorCell.getBoundingClientRect();
|
|
3605
|
+
return y >= gc.top && y <= tableRect.top &&
|
|
3606
|
+
x >= cellRect.left - TE_GRIP_ZONE_PAD_PX && x <= cellRect.right + TE_GRIP_ZONE_PAD_PX;
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
// Recomputes grip visibility/position from the latest throttled pointer
|
|
3610
|
+
// coordinates — called from the mousemove listener wired near the bottom
|
|
3611
|
+
// of this file. `target` is whatever element was directly under the
|
|
3612
|
+
// pointer (Event#target) at those coordinates, same contract
|
|
3613
|
+
// updateTableInsertBubbles() above uses.
|
|
3614
|
+
function updateTableEdgeGrips(x, y, target) {
|
|
3615
|
+
// Both grips sit OUTSIDE the table (position: fixed, appended to
|
|
3616
|
+
// document.body — same as the hover-insert bubbles), so the moment the
|
|
3617
|
+
// real pointer crosses from a cell onto the grip itself, `target` is no
|
|
3618
|
+
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
3619
|
+
// Without this guard, that transition would hit the "nothing found"
|
|
3620
|
+
// branches below and hide the very grip the pointer just moved onto —
|
|
3621
|
+
// pulling it out from under a user trying to click/press it. Leave
|
|
3622
|
+
// whatever was last shown untouched instead; hideTableGrips() (called
|
|
3623
|
+
// from table-leave, burst-end, and drag-start elsewhere) already covers
|
|
3624
|
+
// every path that actually needs to clear it.
|
|
3625
|
+
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) return;
|
|
3626
|
+
const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
|
|
3627
|
+
const tableEl = blockEl ? blockContentEl(blockEl) : null;
|
|
3628
|
+
const cellEl = (tableEl && target && target.closest) ? target.closest('th, td') : null;
|
|
3629
|
+
const onValidCell = !!(tableEl && tableEl.classList.contains('ed-wys-table') && cellEl && tableEl.contains(cellEl));
|
|
3630
|
+
if (!onValidCell) {
|
|
3631
|
+
// Not directly over a table cell — either the pointer genuinely left
|
|
3632
|
+
// the table, or (the bug fixed above) it is travelling through the
|
|
3633
|
+
// corridor toward a grip that's already shown, anchored to ITS OWN
|
|
3634
|
+
// row/column only (see pointInRowGripZone()/pointInColGripZone()'s own
|
|
3635
|
+
// comment for why not the whole table). Keep that grip up while still
|
|
3636
|
+
// in its zone; only actually hide once the pointer has left both
|
|
3637
|
+
// zones entirely.
|
|
3638
|
+
if (pointInRowGripZone(x, y) || pointInColGripZone(x, y)) return;
|
|
3639
|
+
hideTableGrips();
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
3644
|
+
const rowEl = cellEl.parentElement;
|
|
3645
|
+
const headerRow = headerRowOf(tableEl);
|
|
3646
|
+
const colIndex = colIndexOf(cellEl);
|
|
3647
|
+
|
|
3648
|
+
// Row grip: body rows only — the header row is never deletable/
|
|
3649
|
+
// draggable (same rule the retired edge-zone drag gate applied).
|
|
3650
|
+
if (rowEl && rowEl !== headerRow) {
|
|
3651
|
+
gripRowTableEl = tableEl;
|
|
3652
|
+
gripRowEl = rowEl;
|
|
3653
|
+
const r = rowEl.getBoundingClientRect();
|
|
3654
|
+
// Fallback dims match .ed-te-grip-row's own CSS width/height exactly
|
|
3655
|
+
// (20x28) — offsetWidth/Height read 0 while `hidden` (display: none)
|
|
3656
|
+
// is still true on the FIRST show of a hover session, before the
|
|
3657
|
+
// `hidden = false` assignment below takes effect.
|
|
3658
|
+
const gh = rowGrip.offsetHeight || 28;
|
|
3659
|
+
const gw = rowGrip.offsetWidth || 20;
|
|
3660
|
+
rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
|
|
3661
|
+
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
3662
|
+
rowGrip.hidden = false;
|
|
3663
|
+
} else {
|
|
3664
|
+
gripRowTableEl = null;
|
|
3665
|
+
gripRowEl = null;
|
|
3666
|
+
rowGrip.hidden = true;
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// Column grip: every column, positioned against the HEADER cell's own
|
|
3670
|
+
// span (falling back to the hovered cell's own span if the table has no
|
|
3671
|
+
// header — defensive; every WYSIWYG-armed table has one in practice).
|
|
3672
|
+
gripColTableEl = tableEl;
|
|
3673
|
+
gripColIndex = colIndex;
|
|
3674
|
+
const headerCell = headerRow ? headerRow.cells[colIndex] : null;
|
|
3675
|
+
const cr = (headerCell || cellEl).getBoundingClientRect();
|
|
3676
|
+
// Fallback dims match .ed-te-grip-col's own CSS width/height (28x24) —
|
|
3677
|
+
// same first-show-while-still-hidden reasoning as the row grip above.
|
|
3678
|
+
const cgh = colGrip.offsetHeight || 24;
|
|
3679
|
+
const cgw = colGrip.offsetWidth || 28;
|
|
3680
|
+
colGrip.style.left = (cr.left + cr.width / 2 - cgw / 2) + 'px';
|
|
3681
|
+
colGrip.style.top = (tableRect.top - cgh / 2) + 'px';
|
|
3682
|
+
colGrip.hidden = false;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
// Whether the pointer landed on a grip at pointerdown — replaces the
|
|
3686
|
+
// retired hitTestEdgeZone()'s pixel-proximity geometry with a simple "is
|
|
3687
|
+
// the target one of the two grip elements" check, returning the exact
|
|
3688
|
+
// same shape ({kind, tableEl, colIndex} or {kind, tableEl, rowEl,
|
|
3689
|
+
// isHeader}) hitTestEdgeZone() used to, so every downstream consumer
|
|
3690
|
+
// below (the drag-threshold check, showColumnMenu()/showRowMenu(),
|
|
3691
|
+
// performRowDrop()) needed NO changes.
|
|
3692
|
+
function hitTestGrip(target) {
|
|
3693
|
+
if (!target || !target.closest) return null;
|
|
3694
|
+
if (target.closest('.ed-te-grip-row')) {
|
|
3695
|
+
if (!gripRowTableEl || !gripRowEl || !document.body.contains(gripRowEl)) return null;
|
|
3696
|
+
return { kind: 'row', tableEl: gripRowTableEl, rowEl: gripRowEl, isHeader: false };
|
|
3697
|
+
}
|
|
3698
|
+
if (target.closest('.ed-te-grip-col')) {
|
|
3699
|
+
if (!gripColTableEl || gripColIndex == null) return null;
|
|
3700
|
+
return { kind: 'col', tableEl: gripColTableEl, colIndex: gripColIndex };
|
|
3701
|
+
}
|
|
3702
|
+
return null;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// The singleton drop-indicator line shown while dragging a row (never one
|
|
3706
|
+
// per boundary — same Global Constraint as the hover-insert bubbles /
|
|
3707
|
+
// edge menu above).
|
|
3708
|
+
const teDropIndicator = document.createElement('div');
|
|
3709
|
+
teDropIndicator.className = 'ed-te-drop-indicator';
|
|
3710
|
+
teDropIndicator.hidden = true;
|
|
3711
|
+
document.body.appendChild(teDropIndicator);
|
|
3712
|
+
|
|
3713
|
+
// Nearest body-row boundary to `clientY` — header excluded (drops always
|
|
3714
|
+
// clamp to the body, per the brief), same "boundary per row" shape
|
|
3715
|
+
// insertRow()'s hover-boundary geometry above uses, just decided by
|
|
3716
|
+
// proximity (a drag always has SOME nearest boundary) rather than a fixed
|
|
3717
|
+
// threshold.
|
|
3718
|
+
function nearestRowDropTarget(tableEl, clientY) {
|
|
3719
|
+
const rows = bodyRowsOf(tableEl);
|
|
3720
|
+
for (let i = 0; i < rows.length; i++) {
|
|
3721
|
+
const r = rows[i].getBoundingClientRect();
|
|
3722
|
+
if (clientY < r.top + r.height / 2) return { beforeRow: rows[i], y: r.top };
|
|
3723
|
+
}
|
|
3724
|
+
const last = rows[rows.length - 1];
|
|
3725
|
+
const headerRow = headerRowOf(tableEl);
|
|
3726
|
+
const y = last ? last.getBoundingClientRect().bottom
|
|
3727
|
+
: (headerRow ? headerRow.getBoundingClientRect().bottom : tableEl.getBoundingClientRect().top);
|
|
3728
|
+
return { beforeRow: null, y };
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
// The in-flight edge-zone pointer gesture (press-then-either-click-or-
|
|
3732
|
+
// drag), or null between gestures. `hit` is whatever hitTestGrip()
|
|
3733
|
+
// returned at pointerdown; `dragging` flips true once TE_DRAG_THRESHOLD_PX
|
|
3734
|
+
// is crossed (row zones only — see the pointermove listener below);
|
|
3735
|
+
// `dropBeforeRow` is filled in by updateDropIndicator() as the pointer
|
|
3736
|
+
// moves while dragging. `pointerId`/`captureEl` back the pointer-capture
|
|
3737
|
+
// review fix below — see cancelTeDrag()'s comment for why this gesture
|
|
3738
|
+
// needs it at all.
|
|
3739
|
+
let tePointer = null;
|
|
3740
|
+
|
|
3741
|
+
function updateDropIndicator(clientY) {
|
|
3742
|
+
const tableEl = tePointer.hit.tableEl;
|
|
3743
|
+
const target = nearestRowDropTarget(tableEl, clientY);
|
|
3744
|
+
tePointer.dropBeforeRow = target.beforeRow;
|
|
3745
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
3746
|
+
teDropIndicator.style.left = tableRect.left + 'px';
|
|
3747
|
+
teDropIndicator.style.width = tableRect.width + 'px';
|
|
3748
|
+
teDropIndicator.style.top = (target.y - 1) + 'px';
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
// Review fix (Critical): best-effort releasePointerCapture() — a no-op
|
|
3752
|
+
// (wrapped in try/catch) when the browser already auto-released it (the
|
|
3753
|
+
// normal case on a clean pointerup/pointercancel) or `captureEl` got
|
|
3754
|
+
// detached from the document in the meantime (e.g. a burst resolution
|
|
3755
|
+
// mid-gesture). Shared by cancelTeDrag() and the pointerup handler below
|
|
3756
|
+
// so capture is released on every exit path, not just the happy one.
|
|
3757
|
+
function releaseTeCapture(st) {
|
|
3758
|
+
if (st && st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
|
|
3759
|
+
try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released/detached — fine */ }
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
// Unconditional cleanup of the in-flight edge-zone gesture — called from
|
|
3764
|
+
// FIVE places: Esc-during-drag (a distinct gesture from Esc-reverts-burst
|
|
3765
|
+
// — see handleTableCellKeydown() above, which owns Escape for a focused
|
|
3766
|
+
// cell; the global keydown listener below intercepts Escape BEFORE that
|
|
3767
|
+
// branch whenever a drag is actually in flight), the new `pointercancel`
|
|
3768
|
+
// and window `blur` listeners below (review fix, Critical — see their own
|
|
3769
|
+
// comments), a DEFENSIVE clear at the top of the `pointerdown` listener
|
|
3770
|
+
// below (in case a PRIOR gesture's pointerup/pointercancel never reached
|
|
3771
|
+
// us at all — same hazard), and every table-burst-end path above
|
|
3772
|
+
// (rerenderAll()/resolveBurst()/revertTableBurstAndEnd()/
|
|
3773
|
+
// tableBurstUndo()/tableBurstRedo()).
|
|
3774
|
+
//
|
|
3775
|
+
// Review fix (Important): the null-out is unconditional on `tePointer`
|
|
3776
|
+
// being set — NOT gated on `.dragging` (the original bug: a burst
|
|
3777
|
+
// resolution landing during the pressed-but-pre-threshold window used to
|
|
3778
|
+
// leave `tePointer` referencing a row/table that innerHTML/rerenderAll()
|
|
3779
|
+
// was about to detach, since this returned early for a non-dragging
|
|
3780
|
+
// gesture). No mutation ever happens here either way — a pre-threshold
|
|
3781
|
+
// press never moved the row, and an in-flight drag only ever moved the
|
|
3782
|
+
// INDICATOR line, never the row itself (see performRowDrop(), the only
|
|
3783
|
+
// place that actually calls insertBefore()) — so there's nothing to
|
|
3784
|
+
// revert regardless of which state this was called from.
|
|
3785
|
+
function cancelTeDrag() {
|
|
3786
|
+
if (!tePointer) return;
|
|
3787
|
+
releaseTeCapture(tePointer);
|
|
3788
|
+
if (tePointer.dragging && tePointer.hit && tePointer.hit.rowEl) {
|
|
3789
|
+
tePointer.hit.rowEl.classList.remove('ed-te-row-dragging');
|
|
3790
|
+
}
|
|
3791
|
+
// The row grip may still be wearing its "active drag handle" visual
|
|
3792
|
+
// (see the pointermove listener below) — strip it unconditionally, same
|
|
3793
|
+
// belt-and-braces reasoning as the `ed-te-row-dragging` removal above.
|
|
3794
|
+
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
3795
|
+
teDropIndicator.hidden = true;
|
|
3796
|
+
tePointer = null;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
async function performRowDrop(tableEl, rowEl, beforeRow) {
|
|
3800
|
+
// Final-review Finding 6: `rowEl`/`beforeRow` are DOM nodes captured at
|
|
3801
|
+
// pointerdown/during the drag — same staleness hazard runDeleteRow()
|
|
3802
|
+
// now guards against (a dirty burst on a DIFFERENT block, resolved
|
|
3803
|
+
// inside ensureTableBurstOpen() below, swaps `.content` and detaches
|
|
3804
|
+
// every node this table's drag was tracking, not just the ones on the
|
|
3805
|
+
// block that committed). Snapshot both as ORDINAL row positions before
|
|
3806
|
+
// that can happen, then re-locate them by position in the live table
|
|
3807
|
+
// afterward, mirroring runDeleteRow()'s own index-based recovery.
|
|
3808
|
+
const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
|
|
3809
|
+
const beforeRowIndex = beforeRow ? allRowsOf(tableEl).indexOf(beforeRow) : -1;
|
|
3810
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3811
|
+
if (!liveTableEl) return;
|
|
3812
|
+
const liveRows = allRowsOf(liveTableEl);
|
|
3813
|
+
const liveRowEl = rowIndex >= 0 ? liveRows[rowIndex] : null;
|
|
3814
|
+
const liveBeforeRow = beforeRowIndex >= 0 ? liveRows[beforeRowIndex] : null;
|
|
3815
|
+
if (!liveRowEl) return;
|
|
3816
|
+
const tbody = liveTableEl.tBodies[0];
|
|
3817
|
+
if (!tbody || liveRowEl.parentElement !== tbody) return;
|
|
3818
|
+
const prevNext = liveRowEl.nextSibling;
|
|
3819
|
+
if (liveBeforeRow && liveBeforeRow.parentElement === tbody) tbody.insertBefore(liveRowEl, liveBeforeRow);
|
|
3820
|
+
else tbody.appendChild(liveRowEl);
|
|
3821
|
+
// Skip the snap when the drop landed exactly where the row already was
|
|
3822
|
+
// (e.g. dropped back onto itself) — no actual reorder happened, so
|
|
3823
|
+
// there's nothing worth adding to the burst's undo history.
|
|
3824
|
+
if (liveRowEl.nextSibling !== prevNext) currentBurst.history.snap('drag-row');
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
document.addEventListener('pointerdown', (e) => {
|
|
3828
|
+
if (e.button !== 0) return;
|
|
3829
|
+
if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
|
|
3830
|
+
// Review fix (Critical): a PRIOR gesture's pointerup/pointercancel may
|
|
3831
|
+
// never have reached us at all (release over browser chrome / the
|
|
3832
|
+
// window edge, the window losing focus without a pointercancel, ...) —
|
|
3833
|
+
// clear any dangling drag state (dimmed row, frozen indicator, stale
|
|
3834
|
+
// capture) BEFORE starting a new one, so a stuck drag can never survive
|
|
3835
|
+
// into the next gesture and a fresh pointerdown always starts clean.
|
|
3836
|
+
if (tePointer) cancelTeDrag();
|
|
3837
|
+
const hit = hitTestGrip(e.target);
|
|
3838
|
+
// A click on a DIFFERENT zone (or entirely outside any zone) dismisses
|
|
3839
|
+
// whatever menu is already open, same "any other click closes the ⠿
|
|
3840
|
+
// menu" precedent wireBlockSelection() follows below — but NOT when
|
|
3841
|
+
// it's the SAME column/row being re-clicked: that case is a toggle,
|
|
3842
|
+
// left to showColumnMenu()/showRowMenu() at pointerup so a bare
|
|
3843
|
+
// re-click (no drag) closes it instead of flicker-closing then
|
|
3844
|
+
// reopening it here.
|
|
3845
|
+
const isSameSelection = hit && teMenuKind === hit.kind && teMenuTableEl === hit.tableEl &&
|
|
3846
|
+
(hit.kind === 'col' ? teMenuColIndex === hit.colIndex : teMenuRowEl === hit.rowEl);
|
|
3847
|
+
if (teMenuKind && !isSameSelection) hideTableEdgeMenu();
|
|
3848
|
+
if (!hit) return;
|
|
3849
|
+
e.preventDefault();
|
|
3850
|
+
tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
|
|
3851
|
+
pointerId: e.pointerId, captureEl: e.target };
|
|
3852
|
+
// Review fix (Critical): setPointerCapture() is what guarantees
|
|
3853
|
+
// pointermove/pointerup/pointercancel keep arriving for THIS pointerId
|
|
3854
|
+
// even once the cursor leaves the table (or the browser window's
|
|
3855
|
+
// client area) mid-drag — without it, dragging a row upward past the
|
|
3856
|
+
// table top (or releasing over the tab bar) can leave the browser
|
|
3857
|
+
// never delivering a pointerup at all, which is exactly the latch bug
|
|
3858
|
+
// this whole review round is about. Best-effort: not every target
|
|
3859
|
+
// supports it (and a detached/exotic target could throw), so this is
|
|
3860
|
+
// belt-and-braces alongside the defensive pointerdown clear above and
|
|
3861
|
+
// the pointercancel/blur listeners below, not the ONLY safeguard.
|
|
3862
|
+
if (typeof e.target.setPointerCapture === 'function') {
|
|
3863
|
+
try { e.target.setPointerCapture(e.pointerId); } catch (err) { /* not capturable here — the other two guards still apply */ }
|
|
3864
|
+
}
|
|
3865
|
+
});
|
|
3866
|
+
|
|
3867
|
+
document.addEventListener('pointermove', (e) => {
|
|
3868
|
+
if (!tePointer) return;
|
|
3869
|
+
if (tePointer.hit.kind !== 'row' || tePointer.hit.isHeader) return; // only draggable body-row zones arm a drag
|
|
3870
|
+
if (!tePointer.dragging) {
|
|
3871
|
+
const dx = e.clientX - tePointer.startX, dy = e.clientY - tePointer.startY;
|
|
3872
|
+
if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
|
|
3873
|
+
tePointer.dragging = true;
|
|
3874
|
+
hideTableEdgeMenu();
|
|
3875
|
+
hideTableInsertBubbles();
|
|
3876
|
+
// The column grip hides like the insert bubbles above (it isn't
|
|
3877
|
+
// meaningful mid row-drag); the ROW grip stays visible and switches to
|
|
3878
|
+
// its "dragging" visual (grabbing cursor) — it IS the drag handle the
|
|
3879
|
+
// user is holding, per the brief ("the active grip may stay as the
|
|
3880
|
+
// drag handle visual").
|
|
3881
|
+
colGrip.hidden = true;
|
|
3882
|
+
rowGrip.classList.add('ed-te-grip-dragging');
|
|
3883
|
+
tePointer.hit.rowEl.classList.add('ed-te-row-dragging');
|
|
3884
|
+
teDropIndicator.hidden = false;
|
|
3885
|
+
}
|
|
3886
|
+
e.preventDefault();
|
|
3887
|
+
updateDropIndicator(e.clientY);
|
|
3888
|
+
});
|
|
3889
|
+
|
|
3890
|
+
document.addEventListener('pointerup', async (e) => {
|
|
3891
|
+
if (!tePointer) return;
|
|
3892
|
+
const st = tePointer;
|
|
3893
|
+
releaseTeCapture(st);
|
|
3894
|
+
tePointer = null;
|
|
3895
|
+
if (st.dragging) {
|
|
3896
|
+
st.hit.rowEl.classList.remove('ed-te-row-dragging');
|
|
3897
|
+
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
3898
|
+
teDropIndicator.hidden = true;
|
|
3899
|
+
await performRowDrop(st.hit.tableEl, st.hit.rowEl, st.dropBeforeRow);
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
// A plain press-release with no drag threshold crossed: open the menu
|
|
3903
|
+
// for whatever zone was hit at pointerdown.
|
|
3904
|
+
if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
|
|
3905
|
+
else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
3906
|
+
});
|
|
3907
|
+
|
|
3908
|
+
// Review fix (Critical): the browser/OS can ABORT a gesture outright —
|
|
3909
|
+
// palm rejection, the captured element getting removed/disabled, some
|
|
3910
|
+
// other UI (a native context menu, a drag-and-drop of different content)
|
|
3911
|
+
// stealing the pointer — in which case `pointerup` never fires at all,
|
|
3912
|
+
// only `pointercancel`. Treated exactly like Esc-during-drag: unconditional
|
|
3913
|
+
// cleanup via cancelTeDrag(), no mutation (the row itself was never
|
|
3914
|
+
// actually moved mid-drag, only the indicator line).
|
|
3915
|
+
document.addEventListener('pointercancel', () => {
|
|
3916
|
+
cancelTeDrag();
|
|
3917
|
+
});
|
|
3918
|
+
|
|
3919
|
+
// Review fix (Critical): the whole BROWSER WINDOW losing focus mid-
|
|
3920
|
+
// gesture (alt-tab, clicking the OS taskbar/another app, ...) is another
|
|
3921
|
+
// way `pointerup`/`pointercancel` can simply never arrive — pointer
|
|
3922
|
+
// capture only guarantees delivery within this browser's own window, not
|
|
3923
|
+
// across a focus change to a different window entirely. Same
|
|
3924
|
+
// unconditional cleanup; harmless no-op via cancelTeDrag()'s own guard
|
|
3925
|
+
// when no gesture is in flight, so this is safe to fire on every blur.
|
|
3926
|
+
window.addEventListener('blur', () => {
|
|
3927
|
+
cancelTeDrag();
|
|
3928
|
+
});
|
|
3929
|
+
|
|
3930
|
+
// ── Phase-2 Task 4: floating selection toolbar (bold/italic/code/link) ──
|
|
3931
|
+
// Shown over a non-collapsed selection INSIDE the active WYSIWYG editor's
|
|
3932
|
+
// content element (see openWysiwygEditor() above, which attaches/detaches
|
|
3933
|
+
// the selectionchange listener driving this per session); hidden on
|
|
3934
|
+
// commit/cancel/selection-collapse; never shown outside a WYSIWYG session.
|
|
3935
|
+
// Built once (like the hover-insert bubbles below) and moved via
|
|
3936
|
+
// document.body append/remove rather than re-created per session.
|
|
3937
|
+
//
|
|
3938
|
+
// Toggle policy (verbatim from the brief): if the ENTIRE selection lies
|
|
3939
|
+
// within one mark element of the target type, unwrap it (remove the
|
|
3940
|
+
// wrapper, keep its content in place). Otherwise wrap the selection's
|
|
3941
|
+
// contents in a new mark element. When the selection PARTIALLY overlaps an
|
|
3942
|
+
// existing mark of that type (touches it but isn't fully inside it), the
|
|
3943
|
+
// simplest deterministic policy — extend the selection to cover that
|
|
3944
|
+
// mark's full extent, then unwrap — is used instead of trying to split the
|
|
3945
|
+
// mark at the selection boundary.
|
|
3946
|
+
//
|
|
3947
|
+
// Marks map EXACTLY to the elements the inline serializer consumes
|
|
3948
|
+
// (STRONG/EM/CODE/A/DEL/U, see inline-md.js's walkChildren) — never a
|
|
3949
|
+
// <span>, which the serializer treats as either transparent (no
|
|
3950
|
+
// attributes) or unsupported (styled). No execCommand anywhere below —
|
|
3951
|
+
// every mutation is plain Range/Node surgery (extractContents/insertNode/
|
|
3952
|
+
// insertBefore).
|
|
3953
|
+
// The open session's current contenteditable edit root: the paragraph/
|
|
3954
|
+
// heading's own content element for a Task 3 session, or (Task 5) the
|
|
3955
|
+
// ACTIVE cell of an open table session — updated as Tab moves which cell
|
|
3956
|
+
// is active, so this always names whatever element the toolbar's mark
|
|
3957
|
+
// toggles should act on right now.
|
|
3958
|
+
let selToolbarEditEl = null;
|
|
3959
|
+
// The currently-attached onSelectionChangeForToolbar function reference (or
|
|
3960
|
+
// null) — kept at this module scope, NOT just inside openWysiwygEditor()'s
|
|
3961
|
+
// closure, specifically so a call site OUTSIDE that closure (rerenderAll()
|
|
3962
|
+
// below) can remove it without needing a reference to the per-session
|
|
3963
|
+
// function itself.
|
|
3964
|
+
let selToolbarListener = null;
|
|
3965
|
+
|
|
3966
|
+
// Idempotent: safe to call any number of times, including when no session
|
|
3967
|
+
// is open (removeEventListener on a null/already-removed listener,
|
|
3968
|
+
// hideSelToolbar() on an already-detached node, and `= null` on an
|
|
3969
|
+
// already-null variable are all no-ops). This is what lets rerenderAll()
|
|
3970
|
+
// below reset this state UNCONDITIONALLY, the same way it already does for
|
|
3971
|
+
// `activeEditor` — see its call site's comment.
|
|
3972
|
+
function resetSelToolbarState() {
|
|
3973
|
+
if (selToolbarListener) {
|
|
3974
|
+
document.removeEventListener('selectionchange', selToolbarListener);
|
|
3975
|
+
selToolbarListener = null;
|
|
3976
|
+
}
|
|
3977
|
+
hideSelToolbar();
|
|
3978
|
+
selToolbarEditEl = null;
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
// Shows/repositions/hides the floating selection toolbar as the selection
|
|
3982
|
+
// changes during an open session. selectionchange (not mouseup) is the
|
|
3983
|
+
// reliable signal — mouseup alone misses keyboard-driven selections
|
|
3984
|
+
// (Shift+arrow, Ctrl+A, …). Module-scope (not nested inside
|
|
3985
|
+
// openWysiwygEditor()) and reads `selToolbarEditEl` fresh on every firing
|
|
3986
|
+
// — rather than a per-session-closed edit-root variable — specifically so
|
|
3987
|
+
// ONE listener, attached ONCE per burst, keeps working for a table burst
|
|
3988
|
+
// too as Tab/click moves which cell is active (see startTableBurst()/
|
|
3989
|
+
// handleTableCellFocusIn() above, which update `selToolbarEditEl` the
|
|
3990
|
+
// same way activateCell() used to — brief: "wire your cell edit root the
|
|
3991
|
+
// same way paragraph editing does (reuse, don't fork)").
|
|
3992
|
+
function onSelectionChangeForToolbar() {
|
|
3993
|
+
const sel = window.getSelection();
|
|
3994
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { hideSelToolbar(); return; }
|
|
3995
|
+
const range = sel.getRangeAt(0);
|
|
3996
|
+
if (!selToolbarEditEl ||
|
|
3997
|
+
!selToolbarEditEl.contains(range.startContainer) ||
|
|
3998
|
+
!selToolbarEditEl.contains(range.endContainer)) {
|
|
3999
|
+
hideSelToolbar();
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
positionSelToolbar(range);
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
// Nearest ancestor of `node` (inclusive) with tagName `tag`, stopping at
|
|
4006
|
+
// (and never crossing) `root` — a mark belonging to a DIFFERENT block must
|
|
4007
|
+
// never be treated as covering this selection.
|
|
4008
|
+
function closestMarkAncestor(node, tag, root) {
|
|
4009
|
+
let n = node;
|
|
4010
|
+
while (n && n !== root) {
|
|
4011
|
+
if (n.nodeType === 1 && n.tagName === tag) return n;
|
|
4012
|
+
n = n.parentNode;
|
|
4013
|
+
}
|
|
4014
|
+
return null;
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
// The entire selection lies within ONE mark element of `tag` iff both
|
|
4018
|
+
// boundary points resolve to the SAME nearest ancestor of that type — a
|
|
4019
|
+
// Range's content is exactly what's between its two boundary points in
|
|
4020
|
+
// document order, so both being inside the same single element guarantees
|
|
4021
|
+
// everything between them is too.
|
|
4022
|
+
function wholeSelectionMark(range, tag, root) {
|
|
4023
|
+
const startMark = closestMarkAncestor(range.startContainer, tag, root);
|
|
4024
|
+
const endMark = closestMarkAncestor(range.endContainer, tag, root);
|
|
4025
|
+
return (startMark && startMark === endMark) ? startMark : null;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
// Removes `el`, keeping its children in place at the same position.
|
|
4029
|
+
// Returns a Range spanning the (now unwrapped) children so the caller can
|
|
4030
|
+
// restore the selection to exactly the content that was inside `el` —
|
|
4031
|
+
// native Range objects are "live" and auto-adjust their boundary points as
|
|
4032
|
+
// the DOM mutates, so this stays correct across the removals below.
|
|
4033
|
+
function unwrapElement(el) {
|
|
4034
|
+
const parent = el.parentNode;
|
|
4035
|
+
const kids = Array.prototype.slice.call(el.childNodes);
|
|
4036
|
+
kids.forEach((k) => parent.insertBefore(k, el));
|
|
4037
|
+
parent.removeChild(el);
|
|
4038
|
+
if (kids.length === 0) return null;
|
|
4039
|
+
const r = document.createRange();
|
|
4040
|
+
r.setStartBefore(kids[0]);
|
|
4041
|
+
r.setEndAfter(kids[kids.length - 1]);
|
|
4042
|
+
return r;
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
// Wraps the range's contents in a brand-new `<tag>` element via Range
|
|
4046
|
+
// surgery (extractContents/insertNode — plain DOM Range methods, NOT
|
|
4047
|
+
// execCommand). Returns a Range spanning the new element's contents.
|
|
4048
|
+
function wrapRangeIn(range, tag) {
|
|
4049
|
+
const el = document.createElement(tag.toLowerCase());
|
|
4050
|
+
el.appendChild(range.extractContents());
|
|
4051
|
+
range.insertNode(el);
|
|
4052
|
+
const r = document.createRange();
|
|
4053
|
+
r.selectNodeContents(el);
|
|
4054
|
+
return r;
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
// Marks of `tag` that the range overlaps (fully or partially) — called
|
|
4058
|
+
// only AFTER wholeSelectionMark() has already returned null, so any hit
|
|
4059
|
+
// here is by construction a partial-overlap case (see the toggle-policy
|
|
4060
|
+
// comment above).
|
|
4061
|
+
function overlappingMarks(range, tag, root) {
|
|
4062
|
+
return Array.prototype.slice.call(root.querySelectorAll(tag))
|
|
4063
|
+
.filter((m) => range.intersectsNode(m));
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// Extends `range` outward to fully cover every mark in `marks` — Range
|
|
4067
|
+
// boundary points are live, so growing the range here is what makes the
|
|
4068
|
+
// later unwrap step remove the WHOLE mark instead of splitting it.
|
|
4069
|
+
function extendRangeOverMarks(range, marks) {
|
|
4070
|
+
const extended = range.cloneRange();
|
|
4071
|
+
marks.forEach((m) => {
|
|
4072
|
+
const mr = document.createRange();
|
|
4073
|
+
mr.selectNode(m);
|
|
4074
|
+
if (mr.compareBoundaryPoints(Range.START_TO_START, extended) < 0) extended.setStartBefore(m);
|
|
4075
|
+
if (mr.compareBoundaryPoints(Range.END_TO_END, extended) > 0) extended.setEndAfter(m);
|
|
4076
|
+
});
|
|
4077
|
+
return extended;
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
// Restores the DOM selection to `r` and repositions the toolbar over it —
|
|
4081
|
+
// used after every wrap/unwrap so a second click on the same (now
|
|
4082
|
+
// re-marked) content sees the right selection, and so the toolbar doesn't
|
|
4083
|
+
// wait on the async native selectionchange event to catch up.
|
|
4084
|
+
function reselectAndReposition(r) {
|
|
4085
|
+
if (!r) { hideSelToolbar(); return; }
|
|
4086
|
+
const sel = window.getSelection();
|
|
4087
|
+
sel.removeAllRanges();
|
|
4088
|
+
sel.addRange(r);
|
|
4089
|
+
positionSelToolbar(r);
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
// Applies the toggle policy for a plain mark type (STRONG/EM/CODE/DEL/U —
|
|
4093
|
+
// link has its own entry point below because it also needs a URL prompt).
|
|
4094
|
+
function applyMarkToggle(tag) {
|
|
4095
|
+
const root = selToolbarEditEl;
|
|
4096
|
+
if (!root) return;
|
|
4097
|
+
const sel = window.getSelection();
|
|
4098
|
+
if (!sel.rangeCount) return;
|
|
4099
|
+
const range = sel.getRangeAt(0);
|
|
4100
|
+
if (range.collapsed) return;
|
|
4101
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
|
|
4102
|
+
|
|
4103
|
+
const whole = wholeSelectionMark(range, tag, root);
|
|
4104
|
+
if (whole) {
|
|
4105
|
+
reselectAndReposition(unwrapElement(whole));
|
|
4106
|
+
snapBurstIfActive(root, 'mark');
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
4109
|
+
const overlapping = overlappingMarks(range, tag, root);
|
|
4110
|
+
if (overlapping.length > 0) {
|
|
4111
|
+
const extended = extendRangeOverMarks(range, overlapping);
|
|
4112
|
+
// Re-query against the EXTENDED range: `overlapping` above was
|
|
4113
|
+
// computed from the original (smaller) range, and every mark the
|
|
4114
|
+
// extended range now fully covers must be removed.
|
|
4115
|
+
overlappingMarks(extended, tag, root).forEach((m) => unwrapElement(m));
|
|
4116
|
+
reselectAndReposition(extended);
|
|
4117
|
+
snapBurstIfActive(root, 'mark');
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
reselectAndReposition(wrapRangeIn(range, tag));
|
|
4121
|
+
snapBurstIfActive(root, 'mark');
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
// Link is its own entry point (not applyMarkToggle) because "unwrap" here
|
|
4125
|
+
// means "prompt to edit or clear the URL", and "wrap" means "prompt for a
|
|
4126
|
+
// URL first" — both need window.prompt() before any DOM surgery happens.
|
|
4127
|
+
function applyLinkToggle() {
|
|
4128
|
+
const root = selToolbarEditEl;
|
|
4129
|
+
if (!root) return;
|
|
4130
|
+
const sel = window.getSelection();
|
|
4131
|
+
if (!sel.rangeCount) return;
|
|
4132
|
+
const range = sel.getRangeAt(0);
|
|
4133
|
+
if (range.collapsed) return;
|
|
4134
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
|
|
4135
|
+
|
|
4136
|
+
const whole = wholeSelectionMark(range, 'A', root);
|
|
4137
|
+
if (whole) {
|
|
4138
|
+
const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
|
|
4139
|
+
if (url === null) return; // cancelled — leave the link untouched
|
|
4140
|
+
if (url.trim() === '') {
|
|
4141
|
+
reselectAndReposition(unwrapElement(whole));
|
|
4142
|
+
} else {
|
|
4143
|
+
whole.setAttribute('href', url.trim());
|
|
4144
|
+
reselectAndReposition(range);
|
|
4145
|
+
}
|
|
4146
|
+
snapBurstIfActive(root, 'mark');
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const overlapping = overlappingMarks(range, 'A', root);
|
|
4150
|
+
if (overlapping.length > 0) {
|
|
4151
|
+
const extended = extendRangeOverMarks(range, overlapping);
|
|
4152
|
+
overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
|
|
4153
|
+
reselectAndReposition(extended);
|
|
4154
|
+
snapBurstIfActive(root, 'mark');
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
const url = window.prompt('連結網址:', 'https://');
|
|
4158
|
+
if (url === null || url.trim() === '') return; // cancelled or empty — no-op
|
|
4159
|
+
const el = document.createElement('a');
|
|
4160
|
+
el.setAttribute('href', url.trim());
|
|
4161
|
+
el.appendChild(range.extractContents());
|
|
4162
|
+
range.insertNode(el);
|
|
4163
|
+
const r = document.createRange();
|
|
4164
|
+
r.selectNodeContents(el);
|
|
4165
|
+
reselectAndReposition(r);
|
|
4166
|
+
snapBurstIfActive(root, 'mark');
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
function buildSelToolbar() {
|
|
4170
|
+
const el = document.createElement('div');
|
|
4171
|
+
el.className = 'ed-seltb';
|
|
4172
|
+
function addBtn(cls, label, ariaLabel, onClick) {
|
|
4173
|
+
const b = document.createElement('button');
|
|
4174
|
+
b.type = 'button';
|
|
4175
|
+
b.className = 'ed-seltb-btn ' + cls;
|
|
4176
|
+
b.textContent = label;
|
|
4177
|
+
b.setAttribute('aria-label', ariaLabel);
|
|
4178
|
+
// Keep the DOM selection intact across the click: without this, the
|
|
4179
|
+
// button (outside the contenteditable root) stealing focus on
|
|
4180
|
+
// mousedown would collapse the selection before the click handler
|
|
4181
|
+
// ever runs, leaving nothing left to act on.
|
|
4182
|
+
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
4183
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
4184
|
+
el.appendChild(b);
|
|
4185
|
+
return b;
|
|
4186
|
+
}
|
|
4187
|
+
addBtn('ed-seltb-b', 'B', 'Bold', () => applyMarkToggle('STRONG'));
|
|
4188
|
+
addBtn('ed-seltb-i', 'I', 'Italic', () => applyMarkToggle('EM'));
|
|
4189
|
+
addBtn('ed-seltb-s', 'S', '刪除線', () => applyMarkToggle('DEL'));
|
|
4190
|
+
addBtn('ed-seltb-u', 'U', '底線', () => applyMarkToggle('U'));
|
|
4191
|
+
addBtn('ed-seltb-code', '<>', 'Code', () => applyMarkToggle('CODE'));
|
|
4192
|
+
addBtn('ed-seltb-link', '\u{1F517}', 'Link', () => applyLinkToggle());
|
|
4193
|
+
return el;
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
const selToolbar = buildSelToolbar();
|
|
4197
|
+
|
|
4198
|
+
function hideSelToolbar() {
|
|
4199
|
+
if (selToolbar.parentNode) selToolbar.parentNode.removeChild(selToolbar);
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
// Viewport-clamped, positioned above the selection by default; falls back
|
|
4203
|
+
// to below when there isn't room above (and is clamped horizontally/
|
|
4204
|
+
// vertically to stay fully on-screen either way). Coordinates are
|
|
4205
|
+
// viewport-relative (getBoundingClientRect()) to match `.ed-seltb`'s
|
|
4206
|
+
// `position: fixed`.
|
|
4207
|
+
function positionSelToolbar(range) {
|
|
4208
|
+
const rect = range.getBoundingClientRect();
|
|
4209
|
+
if (rect.width === 0 && rect.height === 0) { hideSelToolbar(); return; }
|
|
4210
|
+
if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
|
|
4211
|
+
const gap = 8, margin = 4;
|
|
4212
|
+
const tbRect = selToolbar.getBoundingClientRect();
|
|
4213
|
+
let top = rect.top - tbRect.height - gap;
|
|
4214
|
+
if (top < margin) top = rect.bottom + gap; // not enough room above -> below
|
|
4215
|
+
top = Math.max(margin, Math.min(top, window.innerHeight - tbRect.height - margin));
|
|
4216
|
+
let left = rect.left + rect.width / 2 - tbRect.width / 2;
|
|
4217
|
+
left = Math.max(margin, Math.min(left, window.innerWidth - tbRect.width - margin));
|
|
4218
|
+
selToolbar.style.top = top + 'px';
|
|
4219
|
+
selToolbar.style.left = left + 'px';
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
// ── click routing (Task 5: the click-select edit bar's last consumer —
|
|
4223
|
+
// tables — is retired here; T2 already retired it for paragraph/
|
|
4224
|
+
// heading) ──────────────────────────────────────────────────────────
|
|
4225
|
+
// Every block type is now either always-on contenteditable (paragraph/
|
|
4226
|
+
// heading/list root, or every cell of an armed table) or degraded (opens
|
|
4227
|
+
// the raw textarea directly on click, no bar/menu step) — see
|
|
4228
|
+
// armEditables() above. What's left to route here is: the lightbox
|
|
4229
|
+
// exclusion, the ⠿ handle/menu, and opening a degraded block's raw editor.
|
|
4230
|
+
const ED_LIGHTBOX_TARGETS =
|
|
4231
|
+
'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
4232
|
+
|
|
4233
|
+
// Single delegated listener, wired once at the bottom of this file. Async
|
|
4234
|
+
// because clicking another block (or outside any block) while some
|
|
4235
|
+
// block's editor/burst is open must resolve it first via switchAwayFrom()
|
|
4236
|
+
// — see the `activeEditor` / `currentBurst` comments near their
|
|
4237
|
+
// declarations.
|
|
4238
|
+
function wireBlockSelection() {
|
|
4239
|
+
// Final-review Finding 5a (Important): same "keep the burst's focus
|
|
4240
|
+
// intact across the click" idiom every other overlay button in this
|
|
4241
|
+
// file uses (buildTableInsertBubble()/buildTeMenuButton() above) —
|
|
4242
|
+
// DELEGATED here (rather than a per-node listener in
|
|
4243
|
+
// buildGutterHandle() — see that function's own comment for why) since
|
|
4244
|
+
// this button is recreated per-block on every rerenderAll() AND can be
|
|
4245
|
+
// recreated again mid-session by openRawEditor()'s restore(). Without
|
|
4246
|
+
// this, a mousedown on the ⠿ handle for a block with its OWN burst
|
|
4247
|
+
// currently open and dirty blurs the focused editable surface as the
|
|
4248
|
+
// button's default mousedown action, firing the delegated focusout
|
|
4249
|
+
// handler's async switchAwayFrom()->resolveBurst()->rerenderAll()
|
|
4250
|
+
// commit chain BEFORE the click event that opens the menu ever fires.
|
|
4251
|
+
// At human click speed (a real, non-zero gap between mousedown and
|
|
4252
|
+
// mouseup) that commit's /api/render round trip can finish and swap
|
|
4253
|
+
// `.content` — detaching THIS very button — before mouseup, and a
|
|
4254
|
+
// click event never fires at all for a target removed from the
|
|
4255
|
+
// document between mousedown and mouseup: the first click is silently
|
|
4256
|
+
// eaten (no menu opens), and only a second click on the fresh,
|
|
4257
|
+
// re-armed handle actually works. preventDefault() here stops the
|
|
4258
|
+
// button from stealing focus in the first place, so a dirty burst's
|
|
4259
|
+
// own ⠿ click never triggers that race.
|
|
4260
|
+
// §10-gap fix: same reasoning as the ⠿ handle above, for the + insert
|
|
4261
|
+
// button — a mousedown-triggered blur on a dirty burst elsewhere would
|
|
4262
|
+
// otherwise race this button's own click the exact same way.
|
|
4263
|
+
document.addEventListener('mousedown', (e) => {
|
|
4264
|
+
if (e.target && e.target.closest &&
|
|
4265
|
+
(e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
|
|
4266
|
+
});
|
|
4267
|
+
document.addEventListener('click', async (e) => {
|
|
4268
|
+
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); closeInsertMenu(); return; }
|
|
4269
|
+
// showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
|
|
4270
|
+
// .ed-block — so without this guard a click on the banner's own
|
|
4271
|
+
// Dismiss/Reload button (which doesn't stopPropagation()) bubbles up
|
|
4272
|
+
// here and matches "clicked outside any block" below, re-firing
|
|
4273
|
+
// switchAwayFrom() -> commitNow() -> a SECOND /api/render while the
|
|
4274
|
+
// first failure's banner is still what the user is trying to dismiss.
|
|
4275
|
+
// That re-fire fails again (same reason) and shows a NEW banner
|
|
4276
|
+
// immediately after the old one is removed, so the banner never
|
|
4277
|
+
// actually goes away and dismiss re-triggers the failed commit on
|
|
4278
|
+
// every click. Must be excluded before any other branch.
|
|
4279
|
+
if (e.target.closest('.ed-conflict')) return;
|
|
4280
|
+
if (e.target.closest('.ed-seltb')) return; // the selection toolbar's own buttons handle themselves
|
|
4281
|
+
if (e.target.closest('.ed-tb-insert')) return; // belt-and-braces; the bubble's own click stopPropagation()s already
|
|
4282
|
+
if (e.target.closest('.ed-te-menu')) return; // Task 6: the edge menu's own buttons handle themselves
|
|
4283
|
+
// Task 6 (grip handles): a plain click's own `pointerdown`/`pointerup`
|
|
4284
|
+
// pair above already opened the menu — unlike the bubbles/menu
|
|
4285
|
+
// buttons, the grips have no `click` listener of their own to
|
|
4286
|
+
// stopPropagation() here, so without this exclusion the SAME click
|
|
4287
|
+
// would also fall through to "clicked outside any block" below and
|
|
4288
|
+
// fire an unwanted switchAwayFrom() right after the menu just opened.
|
|
4289
|
+
if (e.target.closest('.ed-te-grip')) return;
|
|
4290
|
+
// Task 9: task-list checkbox toggle. The .ed-li-check span is
|
|
4291
|
+
// non-focusable chrome — no per-node listener, routed here by
|
|
4292
|
+
// delegation. Gate BEFORE mutation (same contract as the structural
|
|
4293
|
+
// key handlers in handleLiKeydown): commitListStructure re-serializes
|
|
4294
|
+
// the WHOLE run, so an unsupported li anywhere in it would have its
|
|
4295
|
+
// content silently deleted if we proceeded. Callers of
|
|
4296
|
+
// commitListStructure must always gate pre-mutation.
|
|
4297
|
+
const checkEl = e.target.closest && e.target.closest('.ed-li-check');
|
|
4298
|
+
if (checkEl) {
|
|
4299
|
+
e.preventDefault();
|
|
4300
|
+
const li = checkEl.closest('li.ed-block');
|
|
4301
|
+
if (!li) return;
|
|
4302
|
+
const root = listRunRootOf(checkEl);
|
|
4303
|
+
if (!root) return;
|
|
4304
|
+
if (!listRunSupportsStructuralEdit(root)) { refuseStructuralListEdit(); return; }
|
|
4305
|
+
// Resolve any open burst on another block before mutating. The span
|
|
4306
|
+
// is non-focusable, so mousedown on it does NOT steal focus — the
|
|
4307
|
+
// currently-focused surface's focusout never fires, and currentBurst
|
|
4308
|
+
// stays open until we explicitly resolve it here.
|
|
4309
|
+
// switchAwayFrom() may trigger a safeRerenderAll() that detaches
|
|
4310
|
+
// `checkEl`. Capture the target li's block-id first so we can
|
|
4311
|
+
// re-find it in the post-render DOM.
|
|
4312
|
+
const targetBlockId = li.getAttribute('data-block-id');
|
|
4313
|
+
const ok = await switchAwayFrom();
|
|
4314
|
+
if (!ok) return;
|
|
4315
|
+
// Re-find the li and its checkbox after the potential re-render.
|
|
4316
|
+
const targetLi = targetBlockId
|
|
4317
|
+
? document.querySelector('li.ed-block[data-block-id="' + targetBlockId + '"]')
|
|
4318
|
+
: null;
|
|
4319
|
+
const targetCheck = targetLi && targetLi.querySelector(':scope > .ed-li-check');
|
|
4320
|
+
if (!targetCheck) return;
|
|
4321
|
+
// Re-gate on the post-render DOM in case the burst resolution
|
|
4322
|
+
// changed the run's supported status.
|
|
4323
|
+
const targetRoot = listRunRootOf(targetCheck);
|
|
4324
|
+
if (!targetRoot) return;
|
|
4325
|
+
if (!listRunSupportsStructuralEdit(targetRoot)) { refuseStructuralListEdit(); return; }
|
|
4326
|
+
// Flip state, then serialize the whole run as one undo op.
|
|
4327
|
+
const wasChecked = targetCheck.getAttribute('data-checked') === '1';
|
|
4328
|
+
targetCheck.setAttribute('data-checked', wasChecked ? '0' : '1');
|
|
4329
|
+
targetCheck.setAttribute('aria-checked', String(!wasChecked));
|
|
4330
|
+
// focusStartLine = null: a checkbox click is not a caret gesture;
|
|
4331
|
+
// leave focus wherever the post-commit re-render puts it.
|
|
4332
|
+
await commitListStructure(blockContentEl(targetLi), null, false);
|
|
4333
|
+
return;
|
|
4334
|
+
}
|
|
4335
|
+
// ⠿ handle: toggles its menu for the block it belongs to. ⠿ menu: its
|
|
4336
|
+
// own buttons handle themselves (stopPropagation()). Either way, this
|
|
4337
|
+
// click is fully handled here — never falls through to the
|
|
4338
|
+
// open-a-block logic below.
|
|
4339
|
+
const handleEl = e.target.closest('.ed-handle');
|
|
4340
|
+
if (handleEl) { toggleGutterMenu(handleEl.closest('.ed-block')); return; }
|
|
4341
|
+
if (e.target.closest('.ed-handle-menu')) return;
|
|
4342
|
+
// §10-gap fix: + button / + menu join the same exclusion pattern —
|
|
4343
|
+
// toggle for the button itself, own-buttons-handle-themselves for the
|
|
4344
|
+
// menu (see buildInsertMenu()'s stopPropagation()).
|
|
4345
|
+
const insertBtnEl = e.target.closest('.ed-insert');
|
|
4346
|
+
if (insertBtnEl) { toggleInsertMenu(insertBtnEl.closest('.ed-block')); return; }
|
|
4347
|
+
if (e.target.closest('.ed-insert-menu')) return;
|
|
4348
|
+
// Any other click closes an already-open ⠿ menu / + menu.
|
|
4349
|
+
if (gutterMenuBlockEl) closeGutterMenu();
|
|
4350
|
+
if (insertMenuBlockEl) closeInsertMenu();
|
|
4351
|
+
if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
|
|
4352
|
+
let blockEl = e.target.closest('.ed-block');
|
|
4353
|
+
if (!blockEl) { await switchAwayFrom(); return; } // clicked outside any block
|
|
4354
|
+
|
|
4355
|
+
// Task 5: a table block is now armed exactly like paragraph/heading/
|
|
4356
|
+
// list (see armEditables() above) — an eligible table's cells are
|
|
4357
|
+
// permanently contenteditable (class 'ed-wys-cell', table root class
|
|
4358
|
+
// 'ed-wys-table'), so a click on one is native caret placement (the
|
|
4359
|
+
// delegated focusin listener starts/continues its burst — see
|
|
4360
|
+
// handleTableCellFocusIn() above), same as any other always-on
|
|
4361
|
+
// surface. A table that failed canWysiwygForTable() at arm time never
|
|
4362
|
+
// gets those classes, so it falls straight through to the generic
|
|
4363
|
+
// degraded-block branch below: "click opens in-place source editor"
|
|
4364
|
+
// (Global Constraint) — no bar, no extra step, same as any other
|
|
4365
|
+
// degraded block.
|
|
4366
|
+
const editEl = blockContentEl(blockEl);
|
|
4367
|
+
if ((editEl && (editEl.classList.contains('ed-wys-armed') || editEl.classList.contains('ed-wys-table'))) ||
|
|
4368
|
+
blockEl.querySelector('.ed-raw')) return;
|
|
4369
|
+
// Degraded block, not yet open: click swaps in the raw textarea
|
|
4370
|
+
// immediately — no bar, no menu step. Still resolve whatever else
|
|
4371
|
+
// might be open first, same precondition every other open path uses.
|
|
4372
|
+
const ok = await switchAwayFrom();
|
|
4373
|
+
if (!ok || !document.body.contains(blockEl)) return;
|
|
4374
|
+
openRawEditor(blockEl);
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
// ── save ───────────────────────────────────────────────────────────────
|
|
4379
|
+
async function save() {
|
|
4380
|
+
let res;
|
|
4381
|
+
try {
|
|
4382
|
+
res = await fetch('/api/save', {
|
|
4383
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
4384
|
+
body: JSON.stringify({ fileId: ED.fileId, content: lines.join('\n'), baseMtimeMs: mtimeMs }),
|
|
4385
|
+
});
|
|
4386
|
+
} catch (e) {
|
|
4387
|
+
showBanner('Save failed — network error (' + describeFailure(e) +
|
|
4388
|
+
'); changes NOT saved.', null, null);
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
if (res.status === 200) {
|
|
4392
|
+
let j;
|
|
4393
|
+
try {
|
|
4394
|
+
j = await res.json();
|
|
4395
|
+
} catch (e) {
|
|
4396
|
+
showBanner('Save failed — malformed server response; changes NOT saved.', null, null);
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
mtimeMs = j.mtimeMs;
|
|
4400
|
+
stack.markSaved();
|
|
4401
|
+
setDirty();
|
|
4402
|
+
return;
|
|
4403
|
+
}
|
|
4404
|
+
if (res.status === 409) {
|
|
4405
|
+
showConflictBanner();
|
|
4406
|
+
return;
|
|
4407
|
+
}
|
|
4408
|
+
// Any other status: surface it visibly — never silently drop the
|
|
4409
|
+
// user's edits. Dirty state (and `mtimeMs`) is left untouched, and
|
|
4410
|
+
// there is no auto-retry; the user decides what to do next.
|
|
4411
|
+
const reason = await describeHttpFailure(res);
|
|
4412
|
+
showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
// ── undo / redo ───────────────────────────────────────────────────────
|
|
4416
|
+
async function undo() {
|
|
4417
|
+
// Resolve any open editor BEFORE the .content-replacing swap below —
|
|
4418
|
+
// see the `activeEditor` / switchAwayFrom() comments near its
|
|
4419
|
+
// declaration for why this is required (undo/redo silently detaching an
|
|
4420
|
+
// open-but-unresolved editor was the Finding-4-regression lockout). A
|
|
4421
|
+
// modified editor auto-commits here (pushing its own op onto `stack`
|
|
4422
|
+
// first), so the undo that follows targets whatever is now the newest
|
|
4423
|
+
// op — which, if an auto-commit just happened, IS that commit.
|
|
4424
|
+
if (!(await switchAwayFrom())) return;
|
|
4425
|
+
const prevLines = lines;
|
|
4426
|
+
const r = stack.undo(lines);
|
|
4427
|
+
if (!r) return;
|
|
4428
|
+
lines = r.lines;
|
|
4429
|
+
const ok = await safeRerenderAll();
|
|
4430
|
+
if (!ok) {
|
|
4431
|
+
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
4432
|
+
// what was on screen before this undo was requested.
|
|
4433
|
+
const rollback = stack.redo(lines);
|
|
4434
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
async function redo() {
|
|
4439
|
+
if (!(await switchAwayFrom())) return;
|
|
4440
|
+
const prevLines = lines;
|
|
4441
|
+
const r = stack.redo(lines);
|
|
4442
|
+
if (!r) return;
|
|
4443
|
+
lines = r.lines;
|
|
4444
|
+
const ok = await safeRerenderAll();
|
|
4445
|
+
if (!ok) {
|
|
4446
|
+
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
4447
|
+
const rollback = stack.undo(lines);
|
|
4448
|
+
lines = rollback ? rollback.lines : prevLines;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
// ── global key handling ─────────────────────────────────────────────────
|
|
4453
|
+
document.addEventListener('keydown', (e) => {
|
|
4454
|
+
// Task 6: Esc during an in-flight row drag cancels JUST the drag (no
|
|
4455
|
+
// mutation — the row was never actually moved, only the indicator line
|
|
4456
|
+
// tracked the pointer) — a DISTINCT gesture from Esc-reverts-burst
|
|
4457
|
+
// (handleTableCellKeydown() below), which must never fire for this same
|
|
4458
|
+
// keypress. Checked before EVERY other branch (including Ctrl+S) so a
|
|
4459
|
+
// drag in flight always wins the keystroke.
|
|
4460
|
+
if (tePointer && tePointer.dragging && e.key === 'Escape') {
|
|
4461
|
+
e.preventDefault();
|
|
4462
|
+
cancelTeDrag();
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
// Task 6: Esc with the edge menu open closes JUST the menu — same
|
|
4466
|
+
// "intercept before the wys-cell/wys-armed Escape branches" reasoning,
|
|
4467
|
+
// since the cell that was focused (if any) before the menu opened is
|
|
4468
|
+
// still focused underneath it (the menu's own mousedown preventDefault()
|
|
4469
|
+
// never stole focus).
|
|
4470
|
+
if (teMenuKind && e.key === 'Escape') {
|
|
4471
|
+
e.preventDefault();
|
|
4472
|
+
hideTableEdgeMenu();
|
|
4473
|
+
return;
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's') {
|
|
4477
|
+
e.preventDefault();
|
|
4478
|
+
// Final-review Finding 1 (Critical): this used to call save() directly,
|
|
4479
|
+
// which serializes `lines` — but a mid-burst keystroke (typed, never
|
|
4480
|
+
// blurred) has NOT reached `lines` yet; save() would persist the STALE
|
|
4481
|
+
// pre-burst text and markSaved() would then clear the dirty dot,
|
|
4482
|
+
// silently discarding the just-typed content with no beforeunload
|
|
4483
|
+
// warning left to catch it. Resolve whatever burst/editor is open
|
|
4484
|
+
// FIRST (same precondition undo()/redo()/changeHeadingDepth() use)
|
|
4485
|
+
// so `lines` reflects the latest edit before save() reads it. On a
|
|
4486
|
+
// commit failure switchAwayFrom() returns false — the banner it
|
|
4487
|
+
// already showed is the visible reason save() is skipped; the burst
|
|
4488
|
+
// stays open with the user's text intact rather than saving nothing.
|
|
4489
|
+
switchAwayFrom().then((ok) => { if (ok) save(); });
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
const inTextarea = e.target && e.target.tagName === 'TEXTAREA' &&
|
|
4494
|
+
e.target.classList.contains('ed-raw');
|
|
4495
|
+
if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
|
|
4496
|
+
|
|
4497
|
+
// Task 5: a table cell (class 'ed-wys-cell') owns its own Enter/Tab/
|
|
4498
|
+
// Escape/Ctrl+Z/Ctrl+Y contract, materially different from paragraph/
|
|
4499
|
+
// heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
|
|
4500
|
+
// cell without ending the burst) — handleTableCellKeydown() owns that
|
|
4501
|
+
// entire surface, mirroring handleBurstKeydown() just below.
|
|
4502
|
+
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
4503
|
+
if (cellEl) {
|
|
4504
|
+
handleTableCellKeydown(e, cellEl);
|
|
4505
|
+
return;
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
// Task 2 (Phase 3): a paragraph/heading/list always-on WYSIWYG burst
|
|
4509
|
+
// surface — Enter/Shift+Enter/Esc/Ctrl+Z/Ctrl+Y are all handled
|
|
4510
|
+
// per-surface by handleBurstKeydown() above (which also owns
|
|
4511
|
+
// preventDefault() for those keys), so nothing below this must run for
|
|
4512
|
+
// it either.
|
|
4513
|
+
const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
4514
|
+
if (wysArmedEl) {
|
|
4515
|
+
handleBurstKeydown(e, wysArmedEl);
|
|
4516
|
+
return;
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
if (e.key === 'Escape') {
|
|
4520
|
+
e.preventDefault();
|
|
4521
|
+
closeGutterMenu();
|
|
4522
|
+
closeInsertMenu();
|
|
4523
|
+
return;
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
4527
|
+
e.preventDefault();
|
|
4528
|
+
undo();
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
if ((e.ctrlKey || e.metaKey) &&
|
|
4532
|
+
(e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
|
4533
|
+
e.preventDefault();
|
|
4534
|
+
redo();
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
});
|
|
4538
|
+
|
|
4539
|
+
// ── Task 2 (Phase 3): the delegated focusin/focusout/paste/input set ────
|
|
4540
|
+
// arming a WYSIWYG-eligible block (see armEditables() above) never
|
|
4541
|
+
// attaches anything to it directly — these four listeners, registered
|
|
4542
|
+
// exactly ONCE each at document level, are the entire wiring surface for
|
|
4543
|
+
// every always-on paragraph/heading/list edit surface, no matter how many
|
|
4544
|
+
// times the page is re-armed by rerenderAll(). Gated throughout by the
|
|
4545
|
+
// `.ed-wys-armed` class. Task 5: a table cell's OWN focusin/focusout/
|
|
4546
|
+
// input/paste handling (class 'ed-wys-cell') is a separate branch at the
|
|
4547
|
+
// top of each listener below — see handleTableCellFocusIn() above.
|
|
4548
|
+
document.addEventListener('focusin', async (e) => {
|
|
4549
|
+
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
4550
|
+
if (cellEl) { await handleTableCellFocusIn(cellEl); return; }
|
|
4551
|
+
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
4552
|
+
if (!editEl) return;
|
|
4553
|
+
if (currentBurst && currentBurst.editEl === editEl) return; // already tracking
|
|
4554
|
+
const blockElAtFocus = editEl.closest('.ed-block');
|
|
4555
|
+
const blockId = blockElAtFocus ? blockElAtFocus.getAttribute('data-block-id') : null;
|
|
4556
|
+
// A DIFFERENT surface's burst (or an old-style activeEditor) may still
|
|
4557
|
+
// be resolving from the focusout that just preceded this focusin — see
|
|
4558
|
+
// switchAwayFrom()'s single-flight `switching`. Await it before
|
|
4559
|
+
// starting a new burst so the two never race a concurrent commit.
|
|
4560
|
+
if (switching) await switching;
|
|
4561
|
+
if (currentBurst) return; // a concurrent focusin already won the race
|
|
4562
|
+
let liveEditEl = editEl;
|
|
4563
|
+
if (!document.body.contains(editEl)) {
|
|
4564
|
+
// The awaited resolution above committed successfully and swapped the
|
|
4565
|
+
// whole .content subtree (rerenderAll()), detaching the original
|
|
4566
|
+
// target. Re-resolve the equivalent LIVE node by block id and move
|
|
4567
|
+
// focus there for real — that re-enters this same handler
|
|
4568
|
+
// synchronously (switching is null by now), which starts the burst.
|
|
4569
|
+
const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
|
|
4570
|
+
liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
4571
|
+
if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
|
|
4572
|
+
liveEditEl.focus();
|
|
4573
|
+
return;
|
|
4574
|
+
}
|
|
4575
|
+
startBurst(liveEditEl);
|
|
4576
|
+
});
|
|
4577
|
+
|
|
4578
|
+
document.addEventListener('focusout', (e) => {
|
|
4579
|
+
// showBanner() appends `.ed-conflict` to document.body, OUTSIDE any
|
|
4580
|
+
// .ed-block — focus moving there (e.g. the user clicking its Dismiss/
|
|
4581
|
+
// Reload button) is not a "blur away to commit", it's dismissing the
|
|
4582
|
+
// very banner a FAILED commit just showed. Without this guard, that
|
|
4583
|
+
// click would fire a SECOND, identical, doomed-to-fail commit attempt —
|
|
4584
|
+
// the same failure mode wireBlockSelection()'s click delegator has
|
|
4585
|
+
// always excluded `.ed-conflict` for (see its own comment).
|
|
4586
|
+
if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.ed-conflict')) return;
|
|
4587
|
+
// Task 5: a table burst spans MANY focusable cells (unlike paragraph/
|
|
4588
|
+
// heading/list, where burst.editEl IS the one focused surface) — moving
|
|
4589
|
+
// focus between cells of the SAME table (Tab, or a click on another
|
|
4590
|
+
// cell) must NOT end the burst, only focus leaving the TABLE entirely
|
|
4591
|
+
// does. relatedTarget (the element ABOUT to gain focus) is what decides
|
|
4592
|
+
// that: still inside the table -> no-op (the paired focusin above
|
|
4593
|
+
// already updated activeCellEl); the hover-insert bubbles are also
|
|
4594
|
+
// excluded (belt-and-braces alongside their own mousedown
|
|
4595
|
+
// preventDefault() — see buildTableInsertBubble() above) so a bubble
|
|
4596
|
+
// click's focus dance never looks like "left the table" either.
|
|
4597
|
+
// `suppressTableFocusout` (see its own comment near `currentBurst`'s
|
|
4598
|
+
// declaration) excludes a THIRD case none of the above catches: a
|
|
4599
|
+
// table-burst-internal innerHTML REASSIGNMENT (revert/undo/redo) fires
|
|
4600
|
+
// this same synchronous blur/focusout on the cell it's about to
|
|
4601
|
+
// replace, and — because Chromium unfocuses BEFORE actually detaching
|
|
4602
|
+
// the node — `e.target.closest('table')` still resolves to this live
|
|
4603
|
+
// `tableEl` and `e.relatedTarget` is still unset, so neither
|
|
4604
|
+
// `stillInTable` nor `toOverlay` below would catch it either.
|
|
4605
|
+
if (suppressTableFocusout) return;
|
|
4606
|
+
// Task 8: same guard for a structural list mutation in flight — see
|
|
4607
|
+
// `suppressLiFocusout`'s own comment (next to the table flag) for why this
|
|
4608
|
+
// focusout must not be read as "the user left the surface".
|
|
4609
|
+
if (suppressLiFocusout) return;
|
|
4610
|
+
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
4611
|
+
if (cellEl && currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === cellEl.closest('table')) {
|
|
4612
|
+
const tableEl = currentBurst.editEl;
|
|
4613
|
+
const stillInTable = e.relatedTarget && tableEl.contains(e.relatedTarget);
|
|
4614
|
+
const toOverlay = e.relatedTarget && e.relatedTarget.closest &&
|
|
4615
|
+
(e.relatedTarget.closest('.ed-tb-insert') || e.relatedTarget.closest('.ed-te-menu') ||
|
|
4616
|
+
e.relatedTarget.closest('.ed-te-grip'));
|
|
4617
|
+
if (stillInTable || toOverlay) return;
|
|
4618
|
+
switchAwayFrom().then((ok) => {
|
|
4619
|
+
if (!ok && currentBurst && currentBurst.editEl === tableEl) {
|
|
4620
|
+
(currentBurst.activeCellEl || cellEl).focus();
|
|
4621
|
+
}
|
|
4622
|
+
});
|
|
4623
|
+
return;
|
|
4624
|
+
}
|
|
4625
|
+
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
4626
|
+
if (editEl && currentBurst && currentBurst.editEl === editEl) {
|
|
4627
|
+
// Task 2 (Phase 3): focusing away from an armed surface commits it —
|
|
4628
|
+
// switchAwayFrom() (extended above to resolve `currentBurst` too)
|
|
4629
|
+
// carries over the SAME commit-failure rollback + single-flight
|
|
4630
|
+
// semantics raw-edit/table sessions have always had. On failure the
|
|
4631
|
+
// burst stays open (DOM/history untouched, banner already shown) —
|
|
4632
|
+
// refocusing it here is what makes "stays open" visibly true again
|
|
4633
|
+
// even though native focus had already moved on to whatever the user
|
|
4634
|
+
// clicked.
|
|
4635
|
+
switchAwayFrom().then((ok) => {
|
|
4636
|
+
if (!ok && currentBurst && currentBurst.editEl === editEl) editEl.focus();
|
|
4637
|
+
});
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
// Degraded blocks (brief: "blur commits (changed) or restores
|
|
4641
|
+
// (unchanged)"). Ctrl+Enter/Escape keep working via openRawEditor()'s
|
|
4642
|
+
// own per-instance listener (unchanged); this adds the blur trigger on
|
|
4643
|
+
// top of it, through the same switchAwayFrom()/activeEditor path every
|
|
4644
|
+
// other commit route in this file already uses.
|
|
4645
|
+
const ta = e.target && e.target.matches && e.target.matches('textarea.ed-raw') ? e.target : null;
|
|
4646
|
+
if (!ta) return;
|
|
4647
|
+
const blockEl = ta.closest('.ed-block');
|
|
4648
|
+
// Moving focus to this editor's OWN ✓/✕ controls is not a "blur away"
|
|
4649
|
+
// — let their own click handlers run (which call commit()/restore()
|
|
4650
|
+
// directly) instead of racing them with an extra switchAwayFrom() call.
|
|
4651
|
+
if (e.relatedTarget && blockEl && blockEl.contains(e.relatedTarget)) return;
|
|
4652
|
+
if (!activeEditor || activeEditor.blockEl !== blockEl) return;
|
|
4653
|
+
switchAwayFrom();
|
|
4654
|
+
});
|
|
4655
|
+
|
|
4656
|
+
document.addEventListener('input', (e) => {
|
|
4657
|
+
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
4658
|
+
if (cellEl) {
|
|
4659
|
+
const tableEl = cellEl.closest('table');
|
|
4660
|
+
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) {
|
|
4661
|
+
currentBurst.history.noteTyping();
|
|
4662
|
+
}
|
|
4663
|
+
return;
|
|
4664
|
+
}
|
|
4665
|
+
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
4666
|
+
if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return;
|
|
4667
|
+
currentBurst.history.noteTyping();
|
|
4668
|
+
});
|
|
4669
|
+
|
|
4670
|
+
document.addEventListener('paste', (e) => {
|
|
4671
|
+
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
4672
|
+
if (cellEl) {
|
|
4673
|
+
e.preventDefault();
|
|
4674
|
+
const dt = e.clipboardData || window.clipboardData;
|
|
4675
|
+
insertTextAtCaret(dt ? dt.getData('text/plain') : '');
|
|
4676
|
+
snapBurstIfActive(cellEl.closest('table'), 'paste');
|
|
4677
|
+
return;
|
|
4678
|
+
}
|
|
4679
|
+
const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
4680
|
+
if (!editEl) return;
|
|
4681
|
+
e.preventDefault();
|
|
4682
|
+
const dt = e.clipboardData || window.clipboardData;
|
|
4683
|
+
insertTextAtCaret(dt ? dt.getData('text/plain') : '');
|
|
4684
|
+
snapBurstIfActive(editEl, 'paste');
|
|
4685
|
+
});
|
|
4686
|
+
|
|
4687
|
+
// Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
|
|
4688
|
+
// mousemove listener (never a per-block/per-boundary listener) drives
|
|
4689
|
+
// updateTableInsertBubbles() above. Coalesced to at most once per animation
|
|
4690
|
+
// frame: every mousemove updates the latest known pointer position, but
|
|
4691
|
+
// only the FIRST one in a frame schedules the (idempotent) recompute —
|
|
4692
|
+
// later moves in the same frame just refresh the coordinates it will read.
|
|
4693
|
+
let tbMoveX = 0, tbMoveY = 0, tbMoveTarget = null, tbMoveScheduled = false;
|
|
4694
|
+
document.addEventListener('mousemove', (e) => {
|
|
4695
|
+
tbMoveX = e.clientX; tbMoveY = e.clientY; tbMoveTarget = e.target;
|
|
4696
|
+
if (tbMoveScheduled) return;
|
|
4697
|
+
tbMoveScheduled = true;
|
|
4698
|
+
requestAnimationFrame(() => {
|
|
4699
|
+
tbMoveScheduled = false;
|
|
4700
|
+
// Review fix (Important): this listener is independent of Task 6's
|
|
4701
|
+
// own `pointermove` above and keeps firing every frame regardless —
|
|
4702
|
+
// without this gate, an active row drag would repaint the + bubble
|
|
4703
|
+
// (or reposition/re-show the grips over some OTHER row/column the
|
|
4704
|
+
// cursor is currently dragging across) on TOP of the drop indicator on
|
|
4705
|
+
// every real drag. Explicitly HIDE the insert bubbles and the column
|
|
4706
|
+
// grip (not just skip recomputing) so anything already showing from
|
|
4707
|
+
// the moment just before the drag threshold was crossed doesn't linger
|
|
4708
|
+
// stale for the rest of the gesture. The ROW grip is deliberately left
|
|
4709
|
+
// untouched here — the pointermove listener above already switched it
|
|
4710
|
+
// to its "dragging" visual (see `ed-te-grip-dragging`) as the drag's
|
|
4711
|
+
// own handle, and this gate must not fight that by hiding it or
|
|
4712
|
+
// repositioning it onto whatever row the cursor happens to be over.
|
|
4713
|
+
if (tePointer && tePointer.dragging) { hideTableInsertBubbles(); colGrip.hidden = true; return; }
|
|
4714
|
+
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4715
|
+
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4716
|
+
});
|
|
4717
|
+
});
|
|
4718
|
+
|
|
4719
|
+
window.addEventListener('beforeunload', (e) => {
|
|
4720
|
+
if (stack.dirtyDepth !== 0) {
|
|
4721
|
+
e.preventDefault();
|
|
4722
|
+
e.returnValue = '';
|
|
4723
|
+
return '';
|
|
4724
|
+
}
|
|
4725
|
+
});
|
|
4726
|
+
|
|
4727
|
+
setInterval(() => {
|
|
4728
|
+
// /api/ping requires content-type: application/json like the other
|
|
4729
|
+
// state-changing POST routes (415 otherwise) — see server.js's CORS
|
|
4730
|
+
// defense. A body is included so the header is meaningful, not just
|
|
4731
|
+
// present on an otherwise-empty request.
|
|
4732
|
+
fetch('/api/ping', {
|
|
4733
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
|
4734
|
+
}).catch(() => {});
|
|
4735
|
+
}, 10000);
|
|
4736
|
+
|
|
4737
|
+
armEditables(contentEl);
|
|
4738
|
+
wireBlockSelection();
|
|
4739
|
+
})();
|