@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.
@@ -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
+ })();