@helping-ai-workflow/md2doc 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/blockmap.js +126 -8
- package/lib/editor/client.js +2863 -732
- package/lib/editor/convert-md.js +200 -0
- package/lib/editor/indent-clamp.js +152 -0
- package/lib/editor/inline-md.js +25 -1
- package/lib/editor/list-md.js +500 -4
- package/lib/editor/server.js +69 -2
- package/lib/editor/table-md.js +13 -1
- package/lib/md2doc.js +375 -52
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -10,6 +10,46 @@
|
|
|
10
10
|
return lines.slice(block.startLine - 1, block.endLine).join('\n');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// ── T8 item 1: the range helpers refuse an INVERTED range THEMSELVES ────
|
|
14
|
+
// A block that owns no source line has endLine === startLine - 1 (see
|
|
15
|
+
// blockOwnsNoLine() further down) and blocks do not tile the document, so an
|
|
16
|
+
// inverted range is a shape callers can genuinely arrive at. Both helpers
|
|
17
|
+
// below were written assuming endLine >= startLine, and handed an inverted
|
|
18
|
+
// range neither fails — each does something plausible and wrong:
|
|
19
|
+
// commitRangeEdit() `lines.slice(startLine-1, endLine)` is [], so the
|
|
20
|
+
// unchanged-text test compares against '', and
|
|
21
|
+
// ops.replaceLines() splices without removing — an
|
|
22
|
+
// INSERT where a replace was asked for. Measured:
|
|
23
|
+
// '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' ->
|
|
24
|
+
// '# Doc\n\n- a\n\nZZZ\n- - b\n'.
|
|
25
|
+
// commitRangeRemoval() the blank-line absorption samples `lines[el]` (the
|
|
26
|
+
// range's own first line) and `lines[sl-2]` (a line
|
|
27
|
+
// owned by whatever precedes), finds one blank and
|
|
28
|
+
// deletes it. Measured: '# Doc\n\n- a\n\n- - b\n'
|
|
29
|
+
// -> '# Doc\n\n- a\n- - b\n', silently.
|
|
30
|
+
// That single root cause was fixed FIVE separate times at five call sites
|
|
31
|
+
// (T3 arming, T3 same-line child commit, T4 gutter delete, T4 raw edit,
|
|
32
|
+
// T7 insertBlockBelow). Those guards stay — they can show the user a banner,
|
|
33
|
+
// which this cannot — but the rule now also lives in the one place every
|
|
34
|
+
// path must pass through.
|
|
35
|
+
//
|
|
36
|
+
// The refusal REUSES the existing "nothing changed" return shape (`op: null`,
|
|
37
|
+
// the caller's own arrays handed straight back) plus a `refused` tag, rather
|
|
38
|
+
// than throwing: every call site already has a correct abort path for
|
|
39
|
+
// op === null, and none of the five is inside a try/catch, so a throw would
|
|
40
|
+
// convert a silent wrong edit into an unhandled rejection mid-gesture — a
|
|
41
|
+
// different failure, not a safer one. The tag is what makes the refusal
|
|
42
|
+
// observable; `console.error` is what makes it findable in a real session.
|
|
43
|
+
function refuseInvertedRange(state, who, startLine, endLine) {
|
|
44
|
+
if (endLine >= startLine) return null;
|
|
45
|
+
if (typeof console !== 'undefined' && console && typeof console.error === 'function') {
|
|
46
|
+
console.error('[md2doc] ' + who + ' refused an inverted line range: startLine=' +
|
|
47
|
+
startLine + ' endLine=' + endLine + ' (a block that owns no source line, or a ' +
|
|
48
|
+
'caller that computed a range backwards) — nothing was committed');
|
|
49
|
+
}
|
|
50
|
+
return { lines: state.lines, blocks: state.blocks, op: null, refused: 'inverted-range' };
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
// Pure: apply a raw-edit commit to an EXPLICIT line range (startLine..endLine,
|
|
14
54
|
// 1-indexed inclusive); push onto stack.
|
|
15
55
|
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
@@ -17,6 +57,8 @@
|
|
|
17
57
|
// so that only blocks after the committed range are shifted — not blocks
|
|
18
58
|
// inside it. Guards the no-anchor case (range before every block).
|
|
19
59
|
function commitRangeEdit(state, startLine, endLine, newText) {
|
|
60
|
+
const refusal = refuseInvertedRange(state, 'commitRangeEdit', startLine, endLine);
|
|
61
|
+
if (refusal) return refusal;
|
|
20
62
|
const before = state.lines.slice(startLine - 1, endLine);
|
|
21
63
|
const after = newText.split('\n');
|
|
22
64
|
if (before.join('\n') === after.join('\n')) {
|
|
@@ -48,6 +90,8 @@
|
|
|
48
90
|
// widening endLine to cover an adjacent blank never reaches across into the
|
|
49
91
|
// next real block and mis-shifts it.
|
|
50
92
|
function commitRangeRemoval(state, startLine, endLine) {
|
|
93
|
+
const refusal = refuseInvertedRange(state, 'commitRangeRemoval', startLine, endLine);
|
|
94
|
+
if (refusal) return refusal;
|
|
51
95
|
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
52
96
|
let sl = startLine, el = endLine;
|
|
53
97
|
// state.lines[el] (0-indexed) is the line immediately AFTER the range.
|
|
@@ -103,6 +147,14 @@
|
|
|
103
147
|
// pure insertion/pure removal without a third op shape.
|
|
104
148
|
function commitBlockInsertion(state, blockId, newBlockLines) {
|
|
105
149
|
const block = state.blocks.find((b) => b.id === blockId);
|
|
150
|
+
// T8 item 1, third helper in the same family: the anchor's range is read as
|
|
151
|
+
// an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
|
|
152
|
+
// the trailing-blank probe), so an inverted one puts the new block ABOVE the
|
|
153
|
+
// block it was anchored to, inside the previous one's territory.
|
|
154
|
+
// refuseInvertedRange() takes the range, so the anchor's own is passed.
|
|
155
|
+
const anchorRefusal = refuseInvertedRange(state, 'commitBlockInsertion',
|
|
156
|
+
block.startLine, block.endLine);
|
|
157
|
+
if (anchorRefusal) return anchorRefusal;
|
|
106
158
|
const endLine = block.endLine;
|
|
107
159
|
const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
|
|
108
160
|
const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '';
|
|
@@ -122,6 +174,35 @@
|
|
|
122
174
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
123
175
|
}
|
|
124
176
|
|
|
177
|
+
// ── T8 review MEDIUM-1: the one correct way to undo a failed render ─────
|
|
178
|
+
// Six sites in this file share the shape "commit optimistically, re-render,
|
|
179
|
+
// and if the render failed put `lines` back". The inlined version of that
|
|
180
|
+
// last step was `const rollback = stack.undo(lines); lines = rollback ?
|
|
181
|
+
// rollback.lines : prevLines;` — correct only while EVERY commit pushed an
|
|
182
|
+
// op. Since commitRangeEdit()/commitRangeRemoval()/commitBlockInsertion()
|
|
183
|
+
// learned to REFUSE an inverted range (above), a commit can return
|
|
184
|
+
// `op: null` having pushed nothing, and UndoStack.undo() pops `_done`
|
|
185
|
+
// unconditionally (lib/editor/lineops.js) — so at the two sites that never
|
|
186
|
+
// inspected `result.op` (insertBlockBelow / deleteBlockViaGutter) a refusal
|
|
187
|
+
// followed by a render failure popped and reversed the user's PREVIOUS,
|
|
188
|
+
// UNRELATED edit. Latent only because those two check blockOwnsNoLine()
|
|
189
|
+
// first; S2 gives li blocks a + and it goes live.
|
|
190
|
+
//
|
|
191
|
+
// One helper rather than two `if (result.op === null) return;` lines,
|
|
192
|
+
// because the idiom is copy-pasted and the seventh site will not remember
|
|
193
|
+
// either. Declared in the pure core so it is reachable from node: the branch
|
|
194
|
+
// cannot be driven through a gesture today, and a guard that can only be
|
|
195
|
+
// checked by grepping for its own source text is the shape that already
|
|
196
|
+
// failed review once on this plan.
|
|
197
|
+
//
|
|
198
|
+
// `state` needs `.lines` (the CURRENT, optimistically-assigned array) and
|
|
199
|
+
// `.stack`. Returns the array `lines` should become.
|
|
200
|
+
function rollbackFailedRender(state, result, prevLines) {
|
|
201
|
+
if (!result || result.op === null) return prevLines;
|
|
202
|
+
const rollback = state.stack.undo(state.lines);
|
|
203
|
+
return rollback ? rollback.lines : prevLines;
|
|
204
|
+
}
|
|
205
|
+
|
|
125
206
|
function headingDepthOf(line) {
|
|
126
207
|
const m = line.match(/^(#{1,6})\s?/);
|
|
127
208
|
return m ? m[1].length : 1;
|
|
@@ -141,7 +222,7 @@
|
|
|
141
222
|
}
|
|
142
223
|
|
|
143
224
|
if (typeof module === 'object' && module.exports) {
|
|
144
|
-
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, headingDepthOf, withHeadingDepth };
|
|
225
|
+
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
145
226
|
return; // node: pure core only
|
|
146
227
|
}
|
|
147
228
|
|
|
@@ -150,8 +231,13 @@
|
|
|
150
231
|
const inlineMd = window.md2docInlineMd;
|
|
151
232
|
const tableMd = window.md2docTableMd;
|
|
152
233
|
const listMd = window.md2docListMd;
|
|
234
|
+
const indentClamp = window.md2docIndentClamp;
|
|
235
|
+
const convertMd = window.md2docConvertMd;
|
|
153
236
|
const historyLib = window.md2docHistory;
|
|
154
237
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
238
|
+
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
239
|
+
// 會把它接回這個 EOL,render 一律用 \n(spec §3.11)。
|
|
240
|
+
const EOL = ED.eol || '\n';
|
|
155
241
|
const stack = new ops.UndoStack();
|
|
156
242
|
const baseTitle = document.title;
|
|
157
243
|
const contentEl = document.querySelector('.content');
|
|
@@ -210,10 +296,10 @@
|
|
|
210
296
|
let pristineInsert = null; // { blockId } | null
|
|
211
297
|
|
|
212
298
|
// Task 5 fix (found via a standalone repro harness — see the task-5
|
|
213
|
-
// report): a table
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
299
|
+
// report): a table mutation that DETACHES the focused cell — whether by
|
|
300
|
+
// reassigning tableEl.innerHTML wholesale or by re-parenting the cell/row
|
|
301
|
+
// nodes — removes whichever cell currently has focus. Chromium runs the
|
|
302
|
+
// focus-fixup "unfocus"
|
|
217
303
|
// step (firing a synchronous blur/focusout) BEFORE the node is actually
|
|
218
304
|
// detached — NOT after, as a naive reading of "removed nodes lose focus"
|
|
219
305
|
// would suggest — so at the moment that focusout's handler runs,
|
|
@@ -226,15 +312,35 @@
|
|
|
226
312
|
// called switchAwayFrom() — silently RE-COMMITTING the very state the
|
|
227
313
|
// revert/undo/redo was in the middle of discarding, then wiping focus to
|
|
228
314
|
// <body> once the resulting rerenderAll() swapped .content. Set true for
|
|
229
|
-
// the exact synchronous span of each such
|
|
230
|
-
//
|
|
231
|
-
//
|
|
315
|
+
// the exact synchronous span of each such mutation; the focusout listener
|
|
316
|
+
// checks it FIRST and no-ops the whole branch while set.
|
|
317
|
+
//
|
|
318
|
+
// There are exactly FOUR set-to-true sites, and test/editor-client.test.js
|
|
319
|
+
// asserts that count (plus that every one of them is wrapped in a
|
|
320
|
+
// try/finally that clears the flag even on a throw — a latched-true flag
|
|
321
|
+
// silently disables blur-commits for EVERY block type until reload):
|
|
322
|
+
// 1. tableBurstUndo() — `tableEl.innerHTML = state`
|
|
323
|
+
// 2. tableBurstRedo() — `tableEl.innerHTML = state`
|
|
324
|
+
// 3. performRowDrop() — rebuildTableSections(): a row drop is a PURE
|
|
325
|
+
// MOVE across thead/tbody (any row dragged to the top becomes the
|
|
326
|
+
// header), so the rebuild detaches the focused cell.
|
|
327
|
+
// 4. performColDrop() — the per-row cell-reorder loop, which appendChild()s
|
|
328
|
+
// every row's cells back in the new order, detaching the focused one.
|
|
329
|
+
// revertTableBurstAndEnd() is deliberately NOT on this list, and NOT
|
|
330
|
+
// because anything else guards it: it needs no flag at all because it
|
|
331
|
+
// nulls `currentBurst` and disposes the burst history BEFORE it touches
|
|
332
|
+
// innerHTML — so by the time that rewrite fires Chromium's synchronous
|
|
333
|
+
// blur/focusout, the focusout handler's table branch finds no burst left
|
|
334
|
+
// to resolve and no-ops on its own. See its own comment for the full
|
|
335
|
+
// story. If a fifth site is ever added,
|
|
336
|
+
// update the count in editor-client.test.js deliberately and audit the new
|
|
337
|
+
// site for the same try/finally.
|
|
232
338
|
let suppressTableFocusout = false;
|
|
233
339
|
|
|
234
340
|
// Task 8: the SAME Chromium behaviour, one substrate over — see
|
|
235
341
|
// `suppressTableFocusout` just above for the full description of the quirk.
|
|
236
342
|
// A structural list key (Enter / Tab / Shift+Tab on a per-li block) moves,
|
|
237
|
-
// splits or removes the very
|
|
343
|
+
// splits or removes the very block whose `.ed-li-text` currently has focus,
|
|
238
344
|
// so Chromium runs its unfocus step — firing a synchronous focusout — with
|
|
239
345
|
// the run still in its PRE-mutation shape and `currentBurst` still live.
|
|
240
346
|
// Unguarded, that focusout reaches resolveBurst(), whose li branch happily
|
|
@@ -258,6 +364,8 @@
|
|
|
258
364
|
return fn();
|
|
259
365
|
} finally {
|
|
260
366
|
suppressLiFocusout = false;
|
|
367
|
+
// S1: indents (and therefore run boundaries) may have just moved.
|
|
368
|
+
refreshRunStarts();
|
|
261
369
|
}
|
|
262
370
|
}
|
|
263
371
|
|
|
@@ -575,6 +683,21 @@
|
|
|
575
683
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
576
684
|
const block = blocks.find((b) => b.id === blockId);
|
|
577
685
|
if (!block) return;
|
|
686
|
+
// Task 4 fix round 1 (Critical, second path): a block that owns no source
|
|
687
|
+
// line has an INVERTED range (endLine === startLine - 1) — see
|
|
688
|
+
// blockOwnsNoLine(). Everything below assumes a well-formed interval:
|
|
689
|
+
// extractBlockSource() of an inverted range is '', so the textarea opens
|
|
690
|
+
// EMPTY, and commit() -> commitEdit() -> commitRangeEdit(5, 4, text)
|
|
691
|
+
// INSERTS the text as a new line instead of replacing anything, leaving
|
|
692
|
+
// the clicked item untouched and a stray line in the file (measured:
|
|
693
|
+
// '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' -> '# Doc\n\n- a\n\nZZZ\n- - b\n').
|
|
694
|
+
// The only such blocks today are list items (RULING F-O independently
|
|
695
|
+
// forbids a textarea inside one), and they are never armed, so before
|
|
696
|
+
// Task 4 gave every block a ⠿ this was reached by a plain body click and
|
|
697
|
+
// stayed unreported. Guarded HERE rather than at the click delegator so
|
|
698
|
+
// every caller — the delegated degraded-block click, openRawViaGutter(),
|
|
699
|
+
// the burst-degrade path — is closed by one check.
|
|
700
|
+
if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
578
701
|
|
|
579
702
|
const original = blockEl.innerHTML;
|
|
580
703
|
const source = extractBlockSource(lines, block);
|
|
@@ -678,8 +801,7 @@
|
|
|
678
801
|
// consistent with what the server actually has. Deliberately does
|
|
679
802
|
// NOT call restore() — the editor (and the user's unsaved text)
|
|
680
803
|
// stays open and visible; see the comment above.
|
|
681
|
-
|
|
682
|
-
lines = rollback ? rollback.lines : prevLines;
|
|
804
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
683
805
|
return false;
|
|
684
806
|
}
|
|
685
807
|
// Success: rerenderAll() already replaced the whole .content subtree
|
|
@@ -706,10 +828,11 @@
|
|
|
706
828
|
// A block's rendered content is the .ed-block's single element child
|
|
707
829
|
// (see lib/md2doc.js's editMode wrapper: `<div class="ed-block"
|
|
708
830
|
// ...>${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
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
831
|
+
// Per-li exception (Task 6 / Phase 4): for data-block-type="li" the block's
|
|
832
|
+
// editable content is its child <div class="ed-li-text">, not
|
|
833
|
+
// firstElementChild — S1 made that distinction load-bearing rather than
|
|
834
|
+
// merely defensive, since a flat list block's FIRST element child is always
|
|
835
|
+
// the .ed-li-marker span (and the .ed-li-check span may follow it).
|
|
713
836
|
function blockContentEl(blockEl) {
|
|
714
837
|
if (blockEl.getAttribute && blockEl.getAttribute('data-block-type') === 'li') {
|
|
715
838
|
// Walk childNodes for the DIV with class ed-li-text (Task 4 shape).
|
|
@@ -760,58 +883,432 @@
|
|
|
760
883
|
return !!tableEl && tableMd.serializeTable(tableEl).unsupported.length === 0;
|
|
761
884
|
}
|
|
762
885
|
|
|
763
|
-
// Task 6 (Phase 4): per-li eligibility check —
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
886
|
+
// Task 6 (Phase 4): per-li eligibility check — serialize THIS block alone
|
|
887
|
+
// and refuse to arm it if anything in it cannot round-trip. Returns false
|
|
888
|
+
// when any inline content is unsupported, so one bad item never degrades its
|
|
889
|
+
// siblings.
|
|
890
|
+
//
|
|
891
|
+
// S1: no probe element is built any more. Pre-S1 this had to clone the <li>
|
|
892
|
+
// into a synthetic UL/OL because serializeList() only took a list ROOT;
|
|
893
|
+
// serializeBlocks() takes the block elements directly, so the live element
|
|
894
|
+
// is passed as a one-element run. Both halves of the result are checked:
|
|
895
|
+
// `unsupportedByLi` carries the per-block inline names, `unsupported` is its
|
|
896
|
+
// strict superset (it additionally collects stray TEXT, foreign children,
|
|
897
|
+
// and — flat model, controller note T2-B — the 'P' of a loose item, which
|
|
898
|
+
// reaches the inline serializer as an ordinary unhandled element name).
|
|
899
|
+
//
|
|
900
|
+
// STRUCTURAL_ONLY names are excluded. 'MULTILINE' (a hard-wrapped item, which
|
|
901
|
+
// legitimately owns a RANGE of lines) is reported so the structural gate can
|
|
902
|
+
// refuse Tab / Enter on it, but spec §4.1 keeps text editing unaffected — and
|
|
903
|
+
// arming IS text editing. Roughly a fifth of real-world list items are
|
|
904
|
+
// hard-wrapped, so treating the flag as an arming veto turns whole documents
|
|
905
|
+
// read-only. Filtered by NAME LIST rather than by a hard-coded string so the
|
|
906
|
+
// serializer stays the single source of truth.
|
|
907
|
+
function armBlockingNames(unsupported) {
|
|
908
|
+
return unsupported.filter((n) => listMd.STRUCTURAL_ONLY_UNSUPPORTED.indexOf(n) === -1);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// A block that owns NO SOURCE LINE has nothing to edit, and arming it is
|
|
912
|
+
// actively destructive. Under same-line nesting ('- - b') the outer item's
|
|
913
|
+
// content begins with its child, so blockmap.js gives it
|
|
914
|
+
// endLine === startLine - 1 — an empty range, not an interval. The commit
|
|
915
|
+
// helpers do not special-case that: the per-li degrade path (taken whenever
|
|
916
|
+
// the run holds a loose or hard-wrapped item) hands
|
|
917
|
+
// editedBlock.startLine/endLine straight to commitRangeEdit(), whose
|
|
918
|
+
// replaceLines() computes `slice(0, start-1).concat(new, slice(end))` — with
|
|
919
|
+
// end < start those two slices OVERLAP, so the original line survives AND the
|
|
920
|
+
// new one is inserted:
|
|
921
|
+
//
|
|
922
|
+
// '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n- Z\n- - b\n'
|
|
923
|
+
//
|
|
924
|
+
// Refusing here closes every such path with one rule, instead of teaching
|
|
925
|
+
// seven commit helpers about a range shape none of them expect. The block is
|
|
926
|
+
// still rendered, still selectable, and its CHILD — which does own its line —
|
|
927
|
+
// stays fully editable.
|
|
928
|
+
function blockOwnsNoLine(blockEl) {
|
|
929
|
+
const raw = blockEl.getAttribute('data-block-id');
|
|
930
|
+
if (raw === null) return false; // provisional block: no record yet, not our call
|
|
931
|
+
const rec = blocks.find((b) => b.id === Number(raw));
|
|
932
|
+
return !!rec && rec.endLine < rec.startLine;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Task 4 fix round 1: the message the two blockOwnsNoLine() guards above
|
|
936
|
+
// (openRawEditor / deleteBlockViaGutter) pass into the SHARED
|
|
937
|
+
// refuseStructuralListEdit() helper (defined further down, next to
|
|
938
|
+
// listRunSupportsStructuralEdit() — see its comment for why this is a
|
|
939
|
+
// parameter and not a second function). One constant so both call sites
|
|
940
|
+
// stay byte-identical instead of two hand-typed copies drifting apart.
|
|
941
|
+
const NO_SOURCE_LINE_MESSAGE = '此項目沒有自己的來源行,無法刪除或直接編輯';
|
|
942
|
+
// T7 fix round 1 (LOW-2): insertBlockBelow()'s own wording. A second
|
|
943
|
+
// constant rather than a reuse, because the one above NAMES the two
|
|
944
|
+
// operations it refuses — an insert that answered '無法刪除' would be
|
|
945
|
+
// telling the user something that is not true of the button they pressed.
|
|
946
|
+
const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入區塊';
|
|
947
|
+
|
|
948
|
+
// The list markers standing at the head of one source line, left to right.
|
|
949
|
+
// A same-line nest ('- 1. b') puts one marker per nesting level on the line;
|
|
950
|
+
// this reads them back so a re-emitted ancestor line can keep the bullet
|
|
951
|
+
// character / ordinal delimiter the file already uses. Stops at the first
|
|
952
|
+
// non-marker, so the last entry is the innermost item's own marker.
|
|
953
|
+
function sourceMarkerChain(line) {
|
|
954
|
+
const out = [];
|
|
955
|
+
let rest = typeof line === 'string' ? line : '';
|
|
956
|
+
for (;;) {
|
|
957
|
+
// The optional trailing group is a GFM task checkbox: it is CONTENT, not
|
|
958
|
+
// marker, but it sits between this marker and the next one, so the walk
|
|
959
|
+
// has to step over it or the chain stops at the first task item.
|
|
960
|
+
const m = /^(\s*)(?:([-*+])|(\d{1,9})([.)]))(\s+)(?:\[[ xX]\]\s+)?/.exec(rest);
|
|
961
|
+
if (!m) return out;
|
|
962
|
+
out.push({ bullet: m[2] || null, delim: m[4] || null });
|
|
963
|
+
rest = rest.slice(m[0].length);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Rewrites a serialized marker to use the SOURCE's bullet char / ordinal
|
|
968
|
+
// delimiter, keeping the serializer's own width and ordinal. Both
|
|
969
|
+
// substitutions are single characters, so the marker's column count — which
|
|
970
|
+
// the child's indent prefix was computed against (spec §3.4) — cannot move.
|
|
971
|
+
//
|
|
972
|
+
// Why not just keep the serializer's canonical '-' / '1.': the degrade path
|
|
973
|
+
// rewrites ONE line and leaves its siblings' bytes alone, and marked starts a
|
|
974
|
+
// NEW list token at a bullet-char or delimiter change. Canonicalising this
|
|
975
|
+
// line alone therefore splits the surrounding list in two ('+ a' + '- …'),
|
|
976
|
+
// which is a visible change to items the user never edited.
|
|
977
|
+
// Takes the BULLET ('- ', '2. '), never the whole marker: a task item's
|
|
978
|
+
// marker is bullet + checkbox ('1. [ ] '), and the ordinal delimiter is then
|
|
979
|
+
// no longer at the end of the string for the substitution to find.
|
|
980
|
+
function bulletInSourceStyle(bullet, src) {
|
|
981
|
+
if (!src) return bullet;
|
|
982
|
+
if (src.bullet) return /^[-*+]/.test(bullet) ? src.bullet + bullet.slice(1) : bullet;
|
|
983
|
+
if (src.delim) return bullet.replace(/([.)])(\s*)$/, src.delim + '$2');
|
|
984
|
+
return bullet;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
// Round 5 — the other half of the same-line-nesting problem. Refusing to ARM
|
|
989
|
+
// a zero-line block (above) keeps it from being the TARGET of a commit; it
|
|
990
|
+
// does nothing about the fact that its marker physically STANDS ON its
|
|
991
|
+
// child's source line. '- - b' is one line carrying two markers, and the
|
|
992
|
+
// child's own line range IS that line — so the per-li degrade path, which
|
|
993
|
+
// replaces [editedBlock.startLine, editedBlock.endLine] with only the lines
|
|
994
|
+
// lineMeta attributes to the edited block, overwrote every ancestor marker
|
|
995
|
+
// standing on it:
|
|
996
|
+
//
|
|
997
|
+
// '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n - bZ\n'
|
|
998
|
+
//
|
|
999
|
+
// The child lost its parent — a semantic change (the previous item swallows
|
|
1000
|
+
// it), not a reformat.
|
|
1001
|
+
//
|
|
1002
|
+
// So the replacement re-emits every such ancestor's MARKER on a line of its
|
|
1003
|
+
// own, ahead of the edited block's lines. That is the canonical form the
|
|
1004
|
+
// whole-run path already produces for a tight run ('- a\n-\n - b'), it
|
|
1005
|
+
// round-trips ('-\n - b' and '- - b' are the same tree to marked), and it
|
|
1006
|
+
// removes the zero-line shape from the file, so the ancestor becomes armable
|
|
1007
|
+
// afterwards.
|
|
1008
|
+
//
|
|
1009
|
+
// A content-free TASK ancestor is NOT in this list, and must not be: marked
|
|
1010
|
+
// only reads '[ ]' / '[x]' as a checkbox when content follows on the SAME
|
|
1011
|
+
// line, so a line of its own would downgrade it to literal text. Round 6
|
|
1012
|
+
// moved that case into the serializer, which now carries such an item as a
|
|
1013
|
+
// prefix on its child's line — so it emits no lineMeta entry of its own and
|
|
1014
|
+
// this walk never sees it, while the child's own emitted line (which the
|
|
1015
|
+
// caller slices anyway) already carries its marker. The two forms therefore
|
|
1016
|
+
// stay in one place: lib/editor/list-md.js decides, this only replays.
|
|
1017
|
+
//
|
|
1018
|
+
// MARKER ONLY, never the ancestor's emitted line: an item whose own content
|
|
1019
|
+
// resumes AFTER its child ('- - b' … ' tail') owns that content on a line
|
|
1020
|
+
// OUTSIDE this commit range, so emitting the serializer's full line for it
|
|
1021
|
+
// would duplicate the text. Its indent prefix and marker WIDTH come from
|
|
1022
|
+
// lineMeta's own record rather than from a guess — an ordered outer
|
|
1023
|
+
// contributes three columns, not two (spec §3.4's errata table) — while the
|
|
1024
|
+
// bullet CHARACTERS are taken back from the source line.
|
|
1025
|
+
//
|
|
1026
|
+
// Only ancestors whose startLine EQUALS the edited block's are collected:
|
|
1027
|
+
// that is what "stands on the same source line" means. A zero-line ancestor
|
|
1028
|
+
// higher up the document, or one belonging to an earlier sibling, names a
|
|
1029
|
+
// different line and must not be touched.
|
|
1030
|
+
function sharedMarkerLinesBefore(lineMeta, firstIdx, editedBlock) {
|
|
1031
|
+
const out = [];
|
|
1032
|
+
// The markers on that source line are its nesting levels, outermost first,
|
|
1033
|
+
// and the LAST one is the edited block's own — so a block's marker is found
|
|
1034
|
+
// by its DEPTH, not by counting the entries collected here. A task ancestor
|
|
1035
|
+
// occupies a marker on the line while contributing no lineMeta entry at
|
|
1036
|
+
// all (see above), which is exactly what a running counter would misalign.
|
|
1037
|
+
const chain = sourceMarkerChain(lines[editedBlock.startLine - 1]);
|
|
1038
|
+
const base = editedBlock.indent - (chain.length - 1);
|
|
1039
|
+
for (let k = 0; k < firstIdx; k++) {
|
|
1040
|
+
const m = lineMeta[k];
|
|
1041
|
+
if (!m || m.blockId === null || m.blockId === undefined) continue;
|
|
1042
|
+
const rec = blocks.find((b) => b.id === Number(m.blockId));
|
|
1043
|
+
if (!rec || rec.endLine >= rec.startLine) continue; // owns a line of its own
|
|
1044
|
+
if (rec.startLine !== editedBlock.startLine) continue;
|
|
1045
|
+
const marker = bulletInSourceStyle(m.marker, chain[rec.indent - base]);
|
|
1046
|
+
out.push((m.indentPrefix + marker).replace(/\s+$/, ''));
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function canWysiwygForLi(blockEl) {
|
|
1052
|
+
if (!blockEl) return false;
|
|
1053
|
+
if (blockOwnsNoLine(blockEl)) return false;
|
|
1054
|
+
const res = listMd.serializeBlocks([blockEl]);
|
|
1055
|
+
return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ── S1: one run scan replaces every "walk up to the outermost UL/OL" ─────
|
|
1059
|
+
// The flat model has no <ul>/<ol> nodes left to walk up to: every list item
|
|
1060
|
+
// is a sibling `.ed-block[data-block-type="li"]` and its depth is
|
|
1061
|
+
// `data-indent`. Six helpers used to reach the enclosing list by DOM
|
|
1062
|
+
// ancestry; they all now go through the scan below.
|
|
1063
|
+
//
|
|
1064
|
+
// RUN (spec §3.8): the sibling items bound to the same parent item. Scanning
|
|
1065
|
+
// outward from a block, a run ends at the first li with a SMALLER indent, at
|
|
1066
|
+
// the first same-indent li whose data-list-type differs, or at the first
|
|
1067
|
+
// non-li block. DEEPER items never break the run — they are descendants of
|
|
1068
|
+
// one of its members.
|
|
1069
|
+
//
|
|
1070
|
+
// Rules (a)/(b)/(c) are bit-for-bit the rule lib/editor/list-md.js's
|
|
1071
|
+
// serializeBlocks() applies internally when it restarts an ordinal. If the
|
|
1072
|
+
// two ever disagree, the symptom is a wrong ordinal or a wrong commit range.
|
|
1073
|
+
//
|
|
1074
|
+
// Rule (d) — a run never crosses a `data-list-start="1"` — has no counterpart
|
|
1075
|
+
// in serializeBlocks() because it never needs one: §3.8's three rules cannot
|
|
1076
|
+
// tell two ADJACENT top-level lists of the same type apart (marked emits a
|
|
1077
|
+
// fresh list token for a bullet-char change, so '- a' followed by '* c' is
|
|
1078
|
+
// two lists whose blocks are all indent 0 / type ul), and before flattening
|
|
1079
|
+
// the two <ul> roots carried that distinction. lib/md2doc.js's renderer
|
|
1080
|
+
// stamps the boundary; this scan honours it, so serializeBlocks() is never
|
|
1081
|
+
// handed a span that straddles two lists and the two can never disagree on a
|
|
1082
|
+
// span that actually reaches it.
|
|
1083
|
+
|
|
1084
|
+
function allBlockEls() {
|
|
1085
|
+
return Array.prototype.slice.call(contentEl.querySelectorAll('.ed-block'));
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function liAttrs(el) {
|
|
1089
|
+
if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
|
|
1090
|
+
return {
|
|
1091
|
+
el: el,
|
|
1092
|
+
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
1093
|
+
listType: el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul',
|
|
1094
|
+
listStart: el.getAttribute('data-list-start') === '1',
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// The nearest li `.ed-block` ancestor of `node` (inclusive), or null.
|
|
1099
|
+
// Text nodes are legal input (Selection boundary points are usually text
|
|
1100
|
+
// nodes), so this walks parentNode by hand rather than using .closest().
|
|
1101
|
+
function closestLiBlock(node) {
|
|
1102
|
+
let n = node;
|
|
1103
|
+
while (n) {
|
|
1104
|
+
if (n.nodeType === 1 && n.getAttribute &&
|
|
1105
|
+
n.classList && n.classList.contains('ed-block') &&
|
|
1106
|
+
n.getAttribute('data-block-type') === 'li') return n;
|
|
1107
|
+
n = n.parentNode;
|
|
781
1108
|
}
|
|
782
|
-
|
|
783
|
-
return listMd.serializeList(probe).unsupported.length === 0;
|
|
1109
|
+
return null;
|
|
784
1110
|
}
|
|
785
1111
|
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1112
|
+
// Spec §3.8's run of `blockEl`, as an array of block elements in document
|
|
1113
|
+
// order. Empty when `blockEl` is not a live li block.
|
|
1114
|
+
function runBlocksOf(blockEl) {
|
|
1115
|
+
const self = liAttrs(blockEl);
|
|
1116
|
+
if (!self) return [];
|
|
1117
|
+
const all = allBlockEls();
|
|
1118
|
+
const i = all.indexOf(blockEl);
|
|
1119
|
+
if (i < 0) return [];
|
|
1120
|
+
const out = [blockEl];
|
|
1121
|
+
if (!self.listStart) {
|
|
1122
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
1123
|
+
const a = liAttrs(all[k]);
|
|
1124
|
+
if (!a || a.indent < self.indent) break;
|
|
1125
|
+
if (a.indent === self.indent) {
|
|
1126
|
+
if (a.listType !== self.listType) break;
|
|
1127
|
+
out.unshift(all[k]);
|
|
796
1128
|
}
|
|
1129
|
+
// Rule (d), scoped to THIS depth. A list-start DEEPER than `self` is a
|
|
1130
|
+
// nested sublist hanging off one of the run's own members — it must not
|
|
1131
|
+
// end the run it lives inside. Only a list-start at `self`'s depth (or
|
|
1132
|
+
// shallower, already handled above) is a boundary for this run.
|
|
1133
|
+
if (a.listStart && a.indent <= self.indent) break;
|
|
797
1134
|
}
|
|
798
|
-
cur = cur.parentElement;
|
|
799
1135
|
}
|
|
800
|
-
|
|
1136
|
+
for (let k = i + 1; k < all.length; k++) {
|
|
1137
|
+
const a = liAttrs(all[k]);
|
|
1138
|
+
if (!a || a.indent < self.indent) break;
|
|
1139
|
+
if (a.listStart && a.indent <= self.indent) break; // rule (d), this depth
|
|
1140
|
+
if (a.indent === self.indent) {
|
|
1141
|
+
if (a.listType !== self.listType) break;
|
|
1142
|
+
out.push(all[k]);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return out;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// The COMMIT UNIT for any list edit: the contiguous block span made up of
|
|
1149
|
+
// the OUTERMOST run reachable from `blockEl` plus every descendant of that
|
|
1150
|
+
// run's members. Returned in document order; empty when `blockEl` is not a
|
|
1151
|
+
// live li block.
|
|
1152
|
+
//
|
|
1153
|
+
// Why the outermost run and not `blockEl`'s own: serializeBlocks() rebuilds
|
|
1154
|
+
// the marker-width stack (spec §3.4) as it walks, so a span that STARTS at
|
|
1155
|
+
// indent 2 has no width recorded for depths 0 and 1 and emits its first line
|
|
1156
|
+
// with NO indent at all — i.e. committing a nested run on its own would
|
|
1157
|
+
// promote it to top level and destroy the nesting. Starting at the outermost
|
|
1158
|
+
// depth is also exactly what the pre-S1 code did (listRunRootOf() walked up
|
|
1159
|
+
// to the UL/OL whose parent was not an <li>, i.e. the whole top-level list),
|
|
1160
|
+
// so the committed byte ranges are unchanged by the flattening.
|
|
1161
|
+
//
|
|
1162
|
+
// What DID change — deliberately, per spec §3.8 rule (b) — is that two
|
|
1163
|
+
// adjacent top-level lists of DIFFERENT type are two spans. Pre-S1 they were
|
|
1164
|
+
// already two separate <ul>/<ol> roots, so this is the same behaviour
|
|
1165
|
+
// expressed without the containers.
|
|
1166
|
+
function listRunOf(blockEl) {
|
|
1167
|
+
const self = liAttrs(blockEl);
|
|
1168
|
+
if (!self) return [];
|
|
1169
|
+
const all = allBlockEls();
|
|
1170
|
+
const i = all.indexOf(blockEl);
|
|
1171
|
+
if (i < 0) return [];
|
|
1172
|
+
// 1. Walk back to the shallowest li that still owns `blockEl` — its
|
|
1173
|
+
// outermost ancestor item. Stops at the first non-li block.
|
|
1174
|
+
// The walk stops on its own the moment it reaches indent 0, which is where
|
|
1175
|
+
// every list token's first item sits — so it can never cross into the
|
|
1176
|
+
// PREVIOUS list, and rule (d) needs no break of its own here. It must NOT
|
|
1177
|
+
// be skipped for a list-start block at indent > 0: that is a NESTED list's
|
|
1178
|
+
// first item, whose outermost ancestor is still above it in the same list,
|
|
1179
|
+
// and returning a nested-only span would emit the run with no indent
|
|
1180
|
+
// prefix at all — i.e. de-nest it on commit.
|
|
1181
|
+
let anchor = i;
|
|
1182
|
+
let anchorIndent = self.indent;
|
|
1183
|
+
for (let k = i - 1; k >= 0 && anchorIndent > 0; k--) {
|
|
1184
|
+
const a = liAttrs(all[k]);
|
|
1185
|
+
if (!a) break;
|
|
1186
|
+
if (a.indent < anchorIndent) { anchor = k; anchorIndent = a.indent; }
|
|
1187
|
+
}
|
|
1188
|
+
// 2. That ancestor's own §3.8 run gives the span's first and last MEMBER.
|
|
1189
|
+
const run = runBlocksOf(all[anchor]);
|
|
1190
|
+
if (!run.length) return [];
|
|
1191
|
+
const startIdx = all.indexOf(run[0]);
|
|
1192
|
+
let endIdx = all.indexOf(run[run.length - 1]);
|
|
1193
|
+
// 3. Extend past the last member to cover its subtree.
|
|
1194
|
+
// `a.indent <= anchorIndent` already stops at the next list token's first
|
|
1195
|
+
// item (every token starts at indent 0 relative to its own nesting), so no
|
|
1196
|
+
// separate rule-(d) break belongs here — and a DEEPER list-start is a
|
|
1197
|
+
// nested sublist of the last run member, which the span must include.
|
|
1198
|
+
for (let k = endIdx + 1; k < all.length; k++) {
|
|
1199
|
+
const a = liAttrs(all[k]);
|
|
1200
|
+
if (!a || a.indent <= anchorIndent) break;
|
|
1201
|
+
endIdx = k;
|
|
1202
|
+
}
|
|
1203
|
+
return all.slice(startIdx, endIdx + 1);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Returns { startLine, endLine, firstId } for a run span (as returned by
|
|
1207
|
+
// listRunOf()), looked up in state.blocks by data-block-id. Document order is
|
|
1208
|
+
// monotonic in block id, so first/last suffices.
|
|
1209
|
+
//
|
|
1210
|
+
// A span may contain a PROVISIONAL block — splitListItemAtCaret()'s new item,
|
|
1211
|
+
// which has no data-block-id because it does not exist in `lines` yet. Those
|
|
1212
|
+
// are skipped: the range is the source lines the span currently OCCUPIES, and
|
|
1213
|
+
// a provisional block occupies none. (Pre-S1 this fell out for free because
|
|
1214
|
+
// the provisional <li> carried no `ed-block` class and the querySelectorAll
|
|
1215
|
+
// never saw it; the flat model needs it to be a real block element, so the
|
|
1216
|
+
// skip has to be explicit.) Returns null when no member is resolvable.
|
|
1217
|
+
function runRangeOfBlocks(state, runEls) {
|
|
1218
|
+
if (!runEls || !runEls.length) return null;
|
|
1219
|
+
const resolved = [];
|
|
1220
|
+
runEls.forEach((el) => {
|
|
1221
|
+
const raw = el.getAttribute('data-block-id');
|
|
1222
|
+
if (raw === null) return;
|
|
1223
|
+
const b = state.blocks.find((x) => x.id === Number(raw));
|
|
1224
|
+
if (b) resolved.push(b);
|
|
1225
|
+
});
|
|
1226
|
+
if (!resolved.length) return null;
|
|
1227
|
+
const firstBlock = resolved[0];
|
|
1228
|
+
const lastBlock = resolved[resolved.length - 1];
|
|
1229
|
+
return { startLine: firstBlock.startLine, endLine: lastBlock.endLine, firstId: firstBlock.id };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Convenience wrapper kept at the old call shape: takes any node inside a
|
|
1233
|
+
// list item and resolves its own commit span's line range.
|
|
1234
|
+
function runRangeOf(state, node) {
|
|
1235
|
+
return runRangeOfBlocks(state, listRunOf(closestLiBlock(node)));
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Re-derives `data-run-start` across the whole document. The attribute is
|
|
1239
|
+
// pure CSS chrome (Task 5 resets the ordered counter on it) and no
|
|
1240
|
+
// serializer reads it, but a structural key changes indents WITHOUT a
|
|
1241
|
+
// re-render for the duration of the commit's round trip, so leaving it stale
|
|
1242
|
+
// would show wrong ordinals for that window. Same rule as the renderer's
|
|
1243
|
+
// liRunStartsHere() and serializeBlocks()'s own ordinal restart.
|
|
1244
|
+
function refreshRunStarts() {
|
|
1245
|
+
const all = allBlockEls();
|
|
1246
|
+
let prev = null;
|
|
1247
|
+
const types = [];
|
|
1248
|
+
all.forEach((el) => {
|
|
1249
|
+
const a = liAttrs(el);
|
|
1250
|
+
if (!a) { prev = null; types.length = 0; return; }
|
|
1251
|
+
// Rule (d): a new list token always opens a new run, and closes the runs
|
|
1252
|
+
// open AT ITS OWN DEPTH AND DEEPER — never the shallower ones, which
|
|
1253
|
+
// belong to the list this token is nested inside. data-list-start is
|
|
1254
|
+
// renderer-owned and never rewritten here: it is the only carrier of the
|
|
1255
|
+
// boundary between two adjacent same-type list tokens.
|
|
1256
|
+
if (a.listStart) types.length = Math.min(types.length, a.indent);
|
|
1257
|
+
const isStart = !prev || a.indent > prev.indent || types[a.indent] !== a.listType;
|
|
1258
|
+
for (let k = types.length - 1; k > a.indent; k--) types[k] = undefined;
|
|
1259
|
+
types[a.indent] = a.listType;
|
|
1260
|
+
prev = a;
|
|
1261
|
+
if (isStart) el.setAttribute('data-run-start', '1');
|
|
1262
|
+
else el.removeAttribute('data-run-start');
|
|
1263
|
+
});
|
|
801
1264
|
}
|
|
802
1265
|
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1266
|
+
// The single place a block's depth is written: `data-indent` is what every
|
|
1267
|
+
// serializer and scan reads, and `--ed-indent` is the CSS mirror the flat
|
|
1268
|
+
// renderer emits alongside it. Writing one without the other makes the
|
|
1269
|
+
// screen disagree with the model for the length of a commit round trip.
|
|
1270
|
+
function setBlockIndent(blockEl, indent) {
|
|
1271
|
+
blockEl.setAttribute('data-indent', String(indent));
|
|
1272
|
+
blockEl.style.setProperty('--ed-indent', String(indent));
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Spec §3.4, applied to the DOM: hand the (already-mutated) commit span to
|
|
1276
|
+
// the pure clamp in lib/editor/indent-clamp.js and write back whatever it
|
|
1277
|
+
// says. `opBlockEl` is the block the gesture moved, `opOldIndent` its indent
|
|
1278
|
+
// BEFORE the move (the spec's global convention).
|
|
1279
|
+
//
|
|
1280
|
+
// Scoped to the commit SPAN, never to the whole document: the span is
|
|
1281
|
+
// exactly the set of blocks the following commit re-serializes, so a clamp
|
|
1282
|
+
// confined to it can never widen the byte range an operation touches. On a
|
|
1283
|
+
// document that was legal to begin with — which is every document, since
|
|
1284
|
+
// data-indent is derived from marked's own nesting — this is a no-op, and it
|
|
1285
|
+
// is meant to be. It is here so the ONE definition of "legal indent" lives
|
|
1286
|
+
// in one testable place instead of being re-derived by each key handler.
|
|
1287
|
+
//
|
|
1288
|
+
// Blocks are matched by data-block-id, so a PROVISIONAL block (a split's new
|
|
1289
|
+
// item, id-less) is passed through untouched rather than being addressed by
|
|
1290
|
+
// position.
|
|
1291
|
+
// `opts` is handed straight to clampIndents() — today only `{ removed: true }`,
|
|
1292
|
+
// used by the ⠿ delete below, which must clamp the span it is ABOUT to take a
|
|
1293
|
+
// member out of. The span passed in therefore still CONTAINS `opBlockEl` (it
|
|
1294
|
+
// has to: `opIndex` is an index into it, and rule 2's scope starts after it);
|
|
1295
|
+
// clampIndents() reports no indent for a removed block, so the write-back
|
|
1296
|
+
// below never touches the element that is on its way out.
|
|
1297
|
+
function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1298
|
+
if (!indentClamp || !spanEls || !spanEls.length) return;
|
|
1299
|
+
const opIndex = spanEls.indexOf(opBlockEl);
|
|
1300
|
+
if (opIndex < 0) return;
|
|
1301
|
+
const model = spanEls.map((el, i) => ({
|
|
1302
|
+
id: i, // index-as-id: the span IS the universe here
|
|
1303
|
+
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
1304
|
+
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
1305
|
+
}));
|
|
1306
|
+
indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {}).forEach((r) => {
|
|
1307
|
+
const el = spanEls[r.blockId];
|
|
1308
|
+
if (el && (Number(el.getAttribute('data-indent')) || 0) !== r.indent) {
|
|
1309
|
+
setBlockIndent(el, r.indent);
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
815
1312
|
}
|
|
816
1313
|
|
|
817
1314
|
function placeCaretAtEnd(el) {
|
|
@@ -932,8 +1429,7 @@
|
|
|
932
1429
|
lines = result.lines;
|
|
933
1430
|
const okRender = await safeRerenderAll();
|
|
934
1431
|
if (!okRender) {
|
|
935
|
-
|
|
936
|
-
lines = rollback ? rollback.lines : prevLines;
|
|
1432
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
937
1433
|
}
|
|
938
1434
|
}
|
|
939
1435
|
|
|
@@ -985,19 +1481,34 @@
|
|
|
985
1481
|
editEl.setAttribute('contenteditable', 'true');
|
|
986
1482
|
editEl.classList.add('ed-wys-armed');
|
|
987
1483
|
} else if (blockType === 'li') {
|
|
988
|
-
// Task 6 (Phase 4): per-li arming. Each
|
|
989
|
-
// armed independently: only its
|
|
1484
|
+
// Task 6 (Phase 4): per-li arming. Each
|
|
1485
|
+
// `.ed-block[data-block-type="li"]` is armed independently: only its
|
|
1486
|
+
// own .ed-li-text div becomes
|
|
990
1487
|
// contenteditable when canWysiwygForLi holds, so one unsupported item
|
|
991
1488
|
// does not degrade its siblings.
|
|
992
1489
|
if (editEl && canWysiwygForLi(blockEl)) {
|
|
993
1490
|
editEl.setAttribute('contenteditable', 'true');
|
|
994
1491
|
editEl.classList.add('ed-wys-armed');
|
|
995
1492
|
}
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1493
|
+
// S1: the li now gets the same ⠿ as every other block, at every
|
|
1494
|
+
// indent depth. This is only safe because list-md.js's
|
|
1495
|
+
// serializeBlocks() skips the chrome BY CLASS TOKEN (its LI_CHROME
|
|
1496
|
+
// allowlist) — if that allowlist is ever narrowed, every <button>
|
|
1497
|
+
// here reaches the inline serializer as content, every li reports
|
|
1498
|
+
// 'BUTTON' unsupported and the WHOLE document degrades read-only.
|
|
1499
|
+
//
|
|
1500
|
+
// S2 Task 7 (§6's S1 note item 3, 「+ 對 li 在 S1 隱藏,S2 解除」): the
|
|
1501
|
+
// + is back, in the same order as every other type. What S1 was
|
|
1502
|
+
// waiting for is now true — insertBlockBelow() gates a li anchor on
|
|
1503
|
+
// listRunSupportsStructuralEdit(), anchors the insertion at the end of
|
|
1504
|
+
// the anchor's SUBTREE (so a parent's children are never straddled),
|
|
1505
|
+
// and takes the 清單 kind through the run's own re-serialization, which
|
|
1506
|
+
// is where §3.4's marker-width stack lives. '.ed-insert' is already in
|
|
1507
|
+
// list-md.js's LI_CHROME allowlist, so this adds no NEW element type
|
|
1508
|
+
// for serializeBlocks() to report as unsupported — the `clean` probe in
|
|
1509
|
+
// the S1 li-gutter scenario is what actually holds that.
|
|
1510
|
+
blockEl.appendChild(buildGutterInsertButton());
|
|
1511
|
+
blockEl.appendChild(buildGutterHandle());
|
|
1001
1512
|
return;
|
|
1002
1513
|
} else if (editEl && blockType === 'table' && canWysiwygForTable(editEl)) {
|
|
1003
1514
|
// Task 5 (Phase 3): table cells armed PERMANENTLY at arm time
|
|
@@ -1054,51 +1565,55 @@
|
|
|
1054
1565
|
return el;
|
|
1055
1566
|
}
|
|
1056
1567
|
|
|
1057
|
-
// The single shared ⠿ menu (
|
|
1058
|
-
// moved into whichever block's DOM the user opened it on,
|
|
1059
|
-
// `selToolbar` elsewhere in this file. `gutterMenuBlockEl`
|
|
1060
|
-
// block it's currently open for.
|
|
1568
|
+
// The single shared ⠿ menu (spec §3.7: 轉換成 › / 複製 / 刪除 / MD 原始碼)
|
|
1569
|
+
// — built once, moved into whichever block's DOM the user opened it on,
|
|
1570
|
+
// same pattern as `selToolbar` elsewhere in this file. `gutterMenuBlockEl`
|
|
1571
|
+
// names which block it's currently open for. Because the node is a
|
|
1572
|
+
// SINGLETON, every per-type visibility decision has to be re-applied on
|
|
1573
|
+
// each open (see toggleGutterMenu below), never set once at build time.
|
|
1574
|
+
//
|
|
1575
|
+
// S2: the heading ± pair is gone from here — §3.5 moved that gesture onto
|
|
1576
|
+
// Tab / Shift+Tab, which call the same changeHeadingDepth() this menu used
|
|
1577
|
+
// to. So is ✕: §3.7 closes the menu by Esc or an outside click, both of
|
|
1578
|
+
// which were already wired (the document-level keydown / click handlers
|
|
1579
|
+
// further down), so removing the button removes a button, not a capability.
|
|
1061
1580
|
let gutterMenuBlockEl = null;
|
|
1062
|
-
let
|
|
1581
|
+
let gutterMenuConvert, gutterMenuDuplicate, gutterMenuDelete, gutterMenuMd;
|
|
1582
|
+
// The 轉換成 submenu: a SECOND singleton, built lazily on demand and torn
|
|
1583
|
+
// down with the menu. It carries `ed-handle-menu` as well as its own class
|
|
1584
|
+
// so it inherits the panel's whole visual language AND so the document-level
|
|
1585
|
+
// outside-click handler's `closest('.ed-handle-menu')` exclusion covers it
|
|
1586
|
+
// without a second selector.
|
|
1587
|
+
let convertSubmenu = null;
|
|
1063
1588
|
|
|
1064
1589
|
function buildGutterMenu() {
|
|
1065
1590
|
const el = document.createElement('div');
|
|
1066
1591
|
el.className = 'ed-handle-menu';
|
|
1067
1592
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
});
|
|
1593
|
+
function item(label, aria, onClick) {
|
|
1594
|
+
const b = document.createElement('button');
|
|
1595
|
+
b.type = 'button';
|
|
1596
|
+
b.className = 'ed-handle-menu-btn';
|
|
1597
|
+
b.textContent = label;
|
|
1598
|
+
b.setAttribute('aria-label', aria);
|
|
1599
|
+
b.addEventListener('click', onClick);
|
|
1600
|
+
el.appendChild(b);
|
|
1601
|
+
return b;
|
|
1602
|
+
}
|
|
1079
1603
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
gutterMenuPlus.textContent = '+';
|
|
1084
|
-
gutterMenuPlus.setAttribute('aria-label', 'Increase heading level');
|
|
1085
|
-
gutterMenuPlus.addEventListener('click', (e) => {
|
|
1604
|
+
// 轉換成 is the one item that does NOT close the menu — it grows a
|
|
1605
|
+
// submenu, and a second press folds it back up.
|
|
1606
|
+
gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
|
|
1086
1607
|
e.stopPropagation();
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
changeHeadingDepth(blockEl, 1);
|
|
1608
|
+
if (convertSubmenu) { closeConvertSubmenu(); return; }
|
|
1609
|
+
openConvertSubmenu(gutterMenuConvert);
|
|
1090
1610
|
});
|
|
1091
1611
|
|
|
1092
|
-
|
|
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) => {
|
|
1612
|
+
gutterMenuDuplicate = item('複製', 'Duplicate this block', (e) => {
|
|
1098
1613
|
e.stopPropagation();
|
|
1099
1614
|
const blockEl = gutterMenuBlockEl;
|
|
1100
1615
|
closeGutterMenu();
|
|
1101
|
-
|
|
1616
|
+
duplicateBlockViaMenu(blockEl);
|
|
1102
1617
|
});
|
|
1103
1618
|
|
|
1104
1619
|
// §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
|
|
@@ -1108,38 +1623,65 @@
|
|
|
1108
1623
|
// just calling it from here too, not touching its implementation) via
|
|
1109
1624
|
// deleteBlockViaGutter() below, which resolves any open burst first
|
|
1110
1625
|
// (requirement: structural ops always go through switchAwayFrom()).
|
|
1111
|
-
|
|
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) => {
|
|
1626
|
+
gutterMenuDelete = item('刪除', 'Delete this block', (e) => {
|
|
1117
1627
|
e.stopPropagation();
|
|
1118
1628
|
const blockEl = gutterMenuBlockEl;
|
|
1119
1629
|
closeGutterMenu();
|
|
1120
1630
|
deleteBlockViaGutter(blockEl);
|
|
1121
1631
|
});
|
|
1122
1632
|
|
|
1123
|
-
|
|
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) => {
|
|
1633
|
+
gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
|
|
1129
1634
|
e.stopPropagation();
|
|
1635
|
+
const blockEl = gutterMenuBlockEl;
|
|
1130
1636
|
closeGutterMenu();
|
|
1637
|
+
openRawViaGutter(blockEl);
|
|
1131
1638
|
});
|
|
1132
1639
|
|
|
1133
|
-
el.appendChild(gutterMenuMinus);
|
|
1134
|
-
el.appendChild(gutterMenuPlus);
|
|
1135
|
-
el.appendChild(mdBtn);
|
|
1136
|
-
el.appendChild(deleteBtn);
|
|
1137
|
-
el.appendChild(closeBtn);
|
|
1138
1640
|
return el;
|
|
1139
1641
|
}
|
|
1140
1642
|
const gutterMenu = buildGutterMenu();
|
|
1141
1643
|
|
|
1644
|
+
// Spec §3.2's twelve v1 targets, rendered as a panel anchored to the right
|
|
1645
|
+
// of the 轉換成 row. The panel is a CHILD of the menu, and the menu is
|
|
1646
|
+
// `position: absolute`, so it is the submenu's own containing block and
|
|
1647
|
+
// `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
|
|
1648
|
+
// no viewport arithmetic, and the panel travels with the menu when the menu
|
|
1649
|
+
// is moved into another block.
|
|
1650
|
+
function openConvertSubmenu(anchorBtn) {
|
|
1651
|
+
closeConvertSubmenu();
|
|
1652
|
+
const sub = document.createElement('div');
|
|
1653
|
+
sub.className = 'ed-handle-menu ed-handle-submenu';
|
|
1654
|
+
convertMd.CONVERT_TARGETS.forEach((t) => {
|
|
1655
|
+
const b = document.createElement('button');
|
|
1656
|
+
b.type = 'button';
|
|
1657
|
+
b.className = 'ed-handle-menu-btn';
|
|
1658
|
+
b.textContent = t.label;
|
|
1659
|
+
b.setAttribute('aria-label', 'Convert to ' + t.id);
|
|
1660
|
+
b.addEventListener('click', (e) => {
|
|
1661
|
+
e.stopPropagation();
|
|
1662
|
+
const blockEl = gutterMenuBlockEl;
|
|
1663
|
+
closeGutterMenu();
|
|
1664
|
+
convertBlockViaMenu(blockEl, t.id);
|
|
1665
|
+
});
|
|
1666
|
+
sub.appendChild(b);
|
|
1667
|
+
});
|
|
1668
|
+
sub.style.top = anchorBtn.offsetTop + 'px';
|
|
1669
|
+
anchorBtn.parentNode.appendChild(sub);
|
|
1670
|
+
convertSubmenu = sub;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function closeConvertSubmenu() {
|
|
1674
|
+
if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1142
1677
|
function closeGutterMenu() {
|
|
1678
|
+
// The submenu lives INSIDE the menu, so removing the menu already detaches
|
|
1679
|
+
// it — but `convertSubmenu` would keep pointing at the detached node and
|
|
1680
|
+
// the next 轉換成 press would read it as "already open" and merely fold a
|
|
1681
|
+
// panel nobody can see. Same stale-singleton hazard `gutterMenuBlockEl`
|
|
1682
|
+
// documents just below, and the reason rerenderAll()'s reset list needs no
|
|
1683
|
+
// second entry: it already calls this.
|
|
1684
|
+
closeConvertSubmenu();
|
|
1143
1685
|
gutterMenu.remove();
|
|
1144
1686
|
gutterMenuBlockEl = null;
|
|
1145
1687
|
}
|
|
@@ -1153,11 +1695,40 @@
|
|
|
1153
1695
|
// on a DIFFERENT block would otherwise leave two floating menus up at
|
|
1154
1696
|
// once. closeInsertMenu() is idempotent (safe even when nothing is open).
|
|
1155
1697
|
closeInsertMenu();
|
|
1698
|
+
// A menu re-opened on another block must never inherit the previous
|
|
1699
|
+
// block's expanded submenu — it was built against THAT block and its
|
|
1700
|
+
// targets close over `gutterMenuBlockEl` at click time, so a stale panel
|
|
1701
|
+
// is a panel that converts the wrong block.
|
|
1702
|
+
closeConvertSubmenu();
|
|
1156
1703
|
gutterMenuBlockEl = blockEl;
|
|
1157
1704
|
const blockType = blockEl.getAttribute('data-block-type');
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1705
|
+
// Spec §7: a table block has no 轉換成 at all (there is no target that
|
|
1706
|
+
// could carry a table's cells, and every one of the twelve would destroy
|
|
1707
|
+
// them).
|
|
1708
|
+
//
|
|
1709
|
+
// 'hr' and 'html' are withheld for a different, measured reason: the
|
|
1710
|
+
// gesture would LIE. convert-md strips a block's marker to get its
|
|
1711
|
+
// content, and an <hr> has no content — its source line IS the marker.
|
|
1712
|
+
// Measured: 'hr' → 項目符號列表 writes '- ---', which marked re-lexes
|
|
1713
|
+
// as an hr again, so the file's bytes change, the block type does not,
|
|
1714
|
+
// and no banner is shown. 'hr' → 文字 is a byte no-op, also silent.
|
|
1715
|
+
// An 'html' block is raw passthrough for the same reason: there is no
|
|
1716
|
+
// marker to strip and no content to re-host. Nothing is lost either way,
|
|
1717
|
+
// but an item that appears to work and does nothing is worse than an
|
|
1718
|
+
// item that is not offered.
|
|
1719
|
+
gutterMenuConvert.hidden = (blockType === 'table' || blockType === 'hr' || blockType === 'html');
|
|
1720
|
+
gutterMenuDuplicate.hidden = false;
|
|
1721
|
+
gutterMenuDelete.hidden = false;
|
|
1722
|
+
// RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
|
|
1723
|
+
// phased measure. openRawEditor() replaces the block's innerHTML with a
|
|
1724
|
+
// <textarea>, and a li is one line of a run that is serialized as a whole
|
|
1725
|
+
// — a textarea inside it is content the serializer cannot represent, and
|
|
1726
|
+
// restore() would have to rebuild the marker/check/text chrome from a
|
|
1727
|
+
// string. Every other block type keeps it: the menu is a SINGLETON moved
|
|
1728
|
+
// between blocks, so this must be reset on every open, not set once.
|
|
1729
|
+
// (test/editor-reader-rebind.test.js drives raw-edit through this button
|
|
1730
|
+
// by its exact text on a paragraph.)
|
|
1731
|
+
gutterMenuMd.hidden = (blockType === 'li');
|
|
1161
1732
|
blockEl.appendChild(gutterMenu);
|
|
1162
1733
|
}
|
|
1163
1734
|
|
|
@@ -1369,26 +1940,117 @@
|
|
|
1369
1940
|
// recovery idiom used throughout this file), THEN acts.
|
|
1370
1941
|
async function insertBlockBelow(blockEl, kind) {
|
|
1371
1942
|
if (!blockEl) return;
|
|
1372
|
-
|
|
1943
|
+
// T7: captured BEFORE switchAwayFrom(), because that is what can renumber
|
|
1944
|
+
// the ids — see captureBlockIdentity()'s comment.
|
|
1945
|
+
const identity = captureBlockIdentity(blockEl);
|
|
1946
|
+
// S2 Task 7: the FOURTH and last call site of the hole 轉換 (Task 2), 刪除
|
|
1947
|
+
// and 複製 (Task 6) already closed, and the one that was latent only
|
|
1948
|
+
// because a li had no + to press. Finding 5a's delegated mousedown
|
|
1949
|
+
// preventDefault() names '.ed-insert' as well as '.ed-handle', so the
|
|
1950
|
+
// burst survives the press and the commit that lands inside
|
|
1951
|
+
// switchAwayFrom() below can be a rewrite of THIS block's own source —
|
|
1952
|
+
// in which case reresolveBlockEl()'s source fingerprint is guaranteed to
|
|
1953
|
+
// miss, because WE are the reason the source changed, and the gesture is
|
|
1954
|
+
// dropped with '文件已更新,請重試這個操作' having done nothing. The
|
|
1955
|
+
// narrowed re-resolve (startLine + type, no fingerprint) is used ONLY
|
|
1956
|
+
// when the session that just committed was this block's OWN;
|
|
1957
|
+
// reresolveBlockEl() keeps its fingerprint for everybody else.
|
|
1958
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
1373
1959
|
const ok = await switchAwayFrom();
|
|
1374
1960
|
if (!ok) return;
|
|
1375
1961
|
let liveBlockEl = blockEl;
|
|
1376
1962
|
if (!document.body.contains(blockEl)) {
|
|
1377
|
-
liveBlockEl =
|
|
1378
|
-
|
|
1963
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
1964
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
1965
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
1966
|
+
}
|
|
1967
|
+
// T7 fix round 1 (LOW-2): same refusal deleteBlockViaGutter() makes below,
|
|
1968
|
+
// for the same reason and against the same LIVE block. A block that owns
|
|
1969
|
+
// no source line has endLine === startLine - 1 (blockOwnsNoLine()), and
|
|
1970
|
+
// commitBlockInsertion() inserts at `endLine + 1` — which for an inverted
|
|
1971
|
+
// range is the line ABOVE the block, i.e. inside whatever precedes it. It
|
|
1972
|
+
// also reads `state.lines[endLine]` to decide the trailing blank, so it
|
|
1973
|
+
// samples a line belonging to somebody else. Latent today only because a
|
|
1974
|
+
// li block grows no + until S2; S2 is next, and a guard that has to be
|
|
1975
|
+
// remembered later is a guard that will not be.
|
|
1976
|
+
if (blockOwnsNoLine(liveBlockEl)) {
|
|
1977
|
+
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
1978
|
+
return;
|
|
1379
1979
|
}
|
|
1380
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
1381
|
-
const block = blocks.find((b) => b.id === liveBlockId);
|
|
1382
|
-
if (!block) return;
|
|
1383
1980
|
const newLines = BLOCK_SKELETONS[kind];
|
|
1384
1981
|
if (!newLines) return;
|
|
1982
|
+
|
|
1983
|
+
// ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
|
|
1984
|
+
//
|
|
1985
|
+
// Two things change, and both were measured against the pure core rather
|
|
1986
|
+
// than reasoned from the plan:
|
|
1987
|
+
//
|
|
1988
|
+
// 1. THE INSERTION POINT IS THE END OF THE ANCHOR'S SUBTREE, not the
|
|
1989
|
+
// anchor's own last line. This is the ruling §4.3 already made for
|
|
1990
|
+
// 複製 (「副本插在該 block 整棵子樹之後」), and it is what makes every
|
|
1991
|
+
// non-list kind safe here. Measured on ['# Doc','','- alpha',
|
|
1992
|
+
// ' - child',' - grand','']: anchored on `child`,
|
|
1993
|
+
// commitBlockInsertion() with the 段落 skeleton yields
|
|
1994
|
+
// '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
|
|
1995
|
+
// marked lexes ' - grand' after a paragraph as an INDENTED CODE
|
|
1996
|
+
// BLOCK — the grandchild's content is gone. Anchored on the end of the
|
|
1997
|
+
// subtree the same gesture yields
|
|
1998
|
+
// '# Doc\n\n- alpha\n - child\n - grand\n\n<ZWSP>\n', whose
|
|
1999
|
+
// token list holds no `code` at all. No kind needs to refuse.
|
|
2000
|
+
//
|
|
2001
|
+
// 2. THE 清單 KIND DOES NOT GO THROUGH commitBlockInsertion() AT ALL.
|
|
2002
|
+
// That function ALWAYS writes a leading blank line (see its own
|
|
2003
|
+
// comment), and for a list that blank is the §4.3 rule 2 defect:
|
|
2004
|
+
// measured, '# Doc\n\n- alpha\n - child\n\n -\n' has a NESTED
|
|
2005
|
+
// list with loose === true, so every item of it grows a <p>,
|
|
2006
|
+
// serializeBlocks() pushes 'P' for each and the run degrades read-only
|
|
2007
|
+
// with no banner. Same fork 複製 hit in Task 6, and the same answer:
|
|
2008
|
+
// route the li through its own run's re-serialization, which emits no
|
|
2009
|
+
// blank at all, re-runs §3.8's renumbering, and — the point of carry 2
|
|
2010
|
+
// — takes the new item's indent prefix from the serializer's own
|
|
2011
|
+
// marker-width stack instead of re-deriving it. There is deliberately
|
|
2012
|
+
// no `indentPrefixOf()` here: `' '.repeat(indent * 2)` is what §3.4
|
|
2013
|
+
// forbids, and even reading lineMeta's `indentPrefix` back would be a
|
|
2014
|
+
// second copy of an arithmetic list-md.js already owns.
|
|
2015
|
+
//
|
|
2016
|
+
// The §4.3 run-wide gate applies on the way in, like every other
|
|
2017
|
+
// structural op. `columnOnly` is the honest option: no EXISTING item's
|
|
2018
|
+
// content or line count is rewritten — the only bytes that move in a
|
|
2019
|
+
// bystander are its marker and leading columns (§3.8 renumbering, applied
|
|
2020
|
+
// as §3.4's colDelta by the carryOver replay), which is exactly the
|
|
2021
|
+
// criterion listRunSupportsStructuralEdit() documents. Without it a
|
|
2022
|
+
// single hard-wrapped item anywhere in the run would veto the +, which
|
|
2023
|
+
// on this repo's own CHANGELOG.md is every run.
|
|
2024
|
+
let anchorEl = liveBlockEl;
|
|
2025
|
+
if (liveBlockEl.getAttribute('data-block-type') === 'li') {
|
|
2026
|
+
const run = listRunOf(liveBlockEl);
|
|
2027
|
+
if (!run.length) return;
|
|
2028
|
+
if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
|
|
2029
|
+
refuseStructuralListEdit();
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
const subtree = subtreeBlocksAfter(liveBlockEl,
|
|
2033
|
+
Number(liveBlockEl.getAttribute('data-indent')) || 0);
|
|
2034
|
+
anchorEl = subtree.length ? subtree[subtree.length - 1] : liveBlockEl;
|
|
2035
|
+
if (kind === 'list') { await insertListItemAfter(liveBlockEl, run, anchorEl); return; }
|
|
2036
|
+
// The subtree's last member is a li like any other, so it can own no
|
|
2037
|
+
// source line for the same reason the anchor could — and it is the block
|
|
2038
|
+
// commitBlockInsertion() is about to read `endLine` and `lines[endLine]`
|
|
2039
|
+
// off. Re-checked against the block actually used.
|
|
2040
|
+
if (blockOwnsNoLine(anchorEl)) {
|
|
2041
|
+
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
|
|
2046
|
+
const block = blocks.find((b) => b.id === liveBlockId);
|
|
2047
|
+
if (!block) return;
|
|
1385
2048
|
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
|
|
1386
2049
|
const prevLines = lines;
|
|
1387
2050
|
lines = result.lines;
|
|
1388
2051
|
const okRender = await safeRerenderAll();
|
|
1389
2052
|
if (!okRender) {
|
|
1390
|
-
|
|
1391
|
-
lines = rollback ? rollback.lines : prevLines;
|
|
2053
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1392
2054
|
return;
|
|
1393
2055
|
}
|
|
1394
2056
|
// §10-gap fix (review): mark the freshly-inserted block "pristine" —
|
|
@@ -1400,33 +2062,856 @@
|
|
|
1400
2062
|
await focusInsertedBlock(result.newStartLine, kind);
|
|
1401
2063
|
}
|
|
1402
2064
|
|
|
2065
|
+
|
|
2066
|
+
// S2 Task 7 — the li half of +. The new item is spliced into the run's own
|
|
2067
|
+
// span and the WHOLE span is re-serialized over the run's line range: one
|
|
2068
|
+
// commitRangeEdit, therefore one undo op, no leading blank line (so the run
|
|
2069
|
+
// stays TIGHT — see insertBlockBelow()'s note 2), §3.8's renumbering for
|
|
2070
|
+
// free, and the new item's indent prefix straight out of list-md.js's
|
|
2071
|
+
// marker-width stack.
|
|
2072
|
+
//
|
|
2073
|
+
// `lastEl` is the end of the anchor's subtree, so the new item is the
|
|
2074
|
+
// anchor's SIBLING and lands after the anchor's children rather than
|
|
2075
|
+
// between them.
|
|
2076
|
+
async function insertListItemAfter(liEl, run, lastEl) {
|
|
2077
|
+
// Captured BEFORE the new item enters the span: it carries no
|
|
2078
|
+
// data-block-id (it does not exist in `lines` yet), so runRangeOfBlocks()
|
|
2079
|
+
// would skip it — but on an insertion after the span's LAST member the
|
|
2080
|
+
// derived range would then silently stop one line short of nothing at all.
|
|
2081
|
+
// Passing the pre-mutation range is the same discipline duplicateListItem()
|
|
2082
|
+
// uses, for the same reason.
|
|
2083
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2084
|
+
if (!range) return;
|
|
2085
|
+
const at = run.indexOf(lastEl);
|
|
2086
|
+
if (at < 0) return;
|
|
2087
|
+
const newLi = buildProvisionalListItem(liEl);
|
|
2088
|
+
const span = run.slice(0, at + 1).concat([newLi], run.slice(at + 1));
|
|
2089
|
+
mutateListRun(() => {
|
|
2090
|
+
lastEl.parentNode.insertBefore(newLi, lastEl.nextSibling);
|
|
2091
|
+
});
|
|
2092
|
+
// No `mutatedEl`: nothing that already existed had its content rewritten,
|
|
2093
|
+
// so every existing member is replayed from the file's own bytes and a
|
|
2094
|
+
// '~5px' stays a '~5px'. The new item has no id and is skipped by
|
|
2095
|
+
// bystanderCarryOver() on its own.
|
|
2096
|
+
const carry = bystanderCarryOver(span);
|
|
2097
|
+
// The SAME map commitListStructure() is about to use — runLineOfBlock()
|
|
2098
|
+
// is an index INTO the lines that map produces, so rebuilding it here
|
|
2099
|
+
// would only usually be the same answer.
|
|
2100
|
+
const focusLine = runLineOfBlock(span, newLi, carry);
|
|
2101
|
+
await commitListStructure(span, focusLine, false,
|
|
2102
|
+
{ presetRange: range, carryOver: carry });
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// A brand-new, empty list item that will become real on the next commit —
|
|
2106
|
+
// the same provisional-block shape splitListItemAtCaret() builds (no
|
|
2107
|
+
// data-block-id: it owns no source line yet, and the commit's rerenderAll()
|
|
2108
|
+
// replaces it with a real, server-numbered block).
|
|
2109
|
+
//
|
|
2110
|
+
// It inherits `data-list-type` and `data-task` from the anchor, NOT the
|
|
2111
|
+
// 清單 menu label's implied bullet. §3.8 rule (b) is why: a different
|
|
2112
|
+
// data-list-type ENDS the run, so a bullet dropped into an ordered run
|
|
2113
|
+
// would split it into three list tokens and renumber what is left. Enter on
|
|
2114
|
+
// a list item (splitListItemAtCaret above) already inherits both, so this is
|
|
2115
|
+
// the established answer rather than a new one.
|
|
2116
|
+
//
|
|
2117
|
+
// `data-list-start` is deliberately NOT copied: it is the only carrier of
|
|
2118
|
+
// "marked opened a new list token here" (§3.8 rule (d)) and a new sibling
|
|
2119
|
+
// inside an existing run is never a token boundary — copying it would
|
|
2120
|
+
// restart the ordinal counter mid-run.
|
|
2121
|
+
function buildProvisionalListItem(anchorLi) {
|
|
2122
|
+
const el = document.createElement('div');
|
|
2123
|
+
el.className = 'ed-block';
|
|
2124
|
+
el.setAttribute('data-block-type', 'li');
|
|
2125
|
+
el.setAttribute('data-list-type', anchorLi.getAttribute('data-list-type') || 'ul');
|
|
2126
|
+
const isTask = anchorLi.getAttribute('data-task') === '1';
|
|
2127
|
+
el.setAttribute('data-task', isTask ? '1' : '0');
|
|
2128
|
+
setBlockIndent(el, Number(anchorLi.getAttribute('data-indent')) || 0);
|
|
2129
|
+
const marker = document.createElement('span');
|
|
2130
|
+
marker.className = 'ed-li-marker';
|
|
2131
|
+
marker.setAttribute('aria-hidden', 'true');
|
|
2132
|
+
el.appendChild(marker);
|
|
2133
|
+
// A fresh item is never checked — nothing in the anchor's line says
|
|
2134
|
+
// otherwise. buildLiCheckbox() is the one place that markup lives, so the
|
|
2135
|
+
// renderer and this stay byte-identical (see its own comment).
|
|
2136
|
+
if (isTask) el.appendChild(buildLiCheckbox());
|
|
2137
|
+
const text = document.createElement('div');
|
|
2138
|
+
text.className = 'ed-li-text';
|
|
2139
|
+
el.appendChild(text);
|
|
2140
|
+
return el;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// ── S2 spec §4.3: 轉換成 ────────────────────────────────────────────────
|
|
2144
|
+
//
|
|
2145
|
+
// The written gesture order is fixed: closeGutterMenu() (the menu item's own
|
|
2146
|
+
// click handler already did it) -> switchAwayFrom() -> re-locate the block by
|
|
2147
|
+
// startLine -> operate. The SOURCE is the resolved `lines`, never the DOM.
|
|
2148
|
+
//
|
|
2149
|
+
// Why line-level rather than "mutate the DOM and re-serialize the run like
|
|
2150
|
+
// every other structural op": list-md.js's serializeBlocks() pushes the
|
|
2151
|
+
// uppercased block type into `unsupported` for any non-`li` block inside the
|
|
2152
|
+
// span it is given, which is EXACTLY the shape a conversion produces — every
|
|
2153
|
+
// li -> heading would hit the degrade path and refuse itself. Reading `lines`
|
|
2154
|
+
// also means the inline content is never re-serialized, so escapeText() never
|
|
2155
|
+
// runs over it and a `~5px` in the converted block stays `~5px`.
|
|
2156
|
+
async function convertBlockViaMenu(blockEl, target) {
|
|
2157
|
+
if (!blockEl || !target) return;
|
|
2158
|
+
const identity = captureBlockIdentity(blockEl);
|
|
2159
|
+
// Whether the session switchAwayFrom() is about to resolve belongs to THIS
|
|
2160
|
+
// block. Finding 5a's mousedown preventDefault() deliberately keeps a dirty
|
|
2161
|
+
// burst alive across the ⠿ press, so the commit that lands inside
|
|
2162
|
+
// switchAwayFrom() below can be a rewrite of the very block we are about to
|
|
2163
|
+
// convert — in which case reresolveBlockEl()'s source fingerprint is
|
|
2164
|
+
// guaranteed to miss, because WE are the reason the source changed. That is
|
|
2165
|
+
// not a dropped gesture; startLine + type still name the block, and the
|
|
2166
|
+
// fingerprint's job (proving an UNRELATED commit did not move somebody else
|
|
2167
|
+
// into this slot) is done by those two here.
|
|
2168
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
2169
|
+
const ok = await switchAwayFrom();
|
|
2170
|
+
if (!ok) return;
|
|
2171
|
+
let liveBlockEl = blockEl;
|
|
2172
|
+
if (!document.body.contains(blockEl)) {
|
|
2173
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2174
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2175
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2176
|
+
}
|
|
2177
|
+
// Same refusal deleteBlockViaGutter() makes, for the same reason: a block
|
|
2178
|
+
// that owns no source line has an INVERTED range (endLine === startLine-1),
|
|
2179
|
+
// and every commit helper handed one does something plausible and wrong.
|
|
2180
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2181
|
+
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2182
|
+
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2183
|
+
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2184
|
+
const kind = liveBlockEl.getAttribute('data-block-type');
|
|
2185
|
+
|
|
2186
|
+
// §4.3's run-wide gate: 轉換/複製/刪除/拖曳 all pass through
|
|
2187
|
+
// listRunSupportsStructuralEdit() BEFORE any mutation, the same door
|
|
2188
|
+
// Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
|
|
2189
|
+
// is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2190
|
+
// descendant of its members) — see deleteListItemViaGutter()'s own note.
|
|
2191
|
+
//
|
|
2192
|
+
// ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
|
|
2193
|
+
// not-yet-implemented refusals below and, further down, of stripMarker():
|
|
2194
|
+
// a multi-line li must report §4.1's 「此清單含不支援的格式,無法調整結構」
|
|
2195
|
+
// and not convert-md.js's per-block 「此區塊的格式無法轉換」, which is what
|
|
2196
|
+
// it would get if stripMarker() saw it first (that function refuses a
|
|
2197
|
+
// multi-line li too, for its own, narrower reason). The runtime scenario
|
|
2198
|
+
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so
|
|
2199
|
+
// it is what notices if this order is ever flipped.
|
|
2200
|
+
//
|
|
2201
|
+
// A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's
|
|
2202
|
+
// own text or line count, so a multi-line li refuses as a TARGET while
|
|
2203
|
+
// remaining a perfectly good bystander.
|
|
2204
|
+
let liRun = null;
|
|
2205
|
+
if (kind === 'li') {
|
|
2206
|
+
liRun = listRunOf(liveBlockEl);
|
|
2207
|
+
if (!liRun.length) return;
|
|
2208
|
+
if (!listRunSupportsStructuralEdit(liRun, liveBlockEl)) { refuseStructuralListEdit(); return; }
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// S2 Task 3: li → a LIST target. The block stays a li, so the run stays a
|
|
2212
|
+
// run and the existing re-serialization machinery applies unchanged.
|
|
2213
|
+
if (kind === 'li' && convertMd.targetIsList(target)) {
|
|
2214
|
+
await convertListItemType(liveBlockEl, liRun, target);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
// S2 Task 4: li → a NON-list target. The item LEAVES the run, so the span
|
|
2218
|
+
// has to be rebuilt in three pieces and §4.3 rule 1's blank lines put
|
|
2219
|
+
// between them — the plain path below would leave the converted line
|
|
2220
|
+
// mid-list with no separator and lazy continuation would swallow it into
|
|
2221
|
+
// the item above (measured, §4.3 rule 1).
|
|
2222
|
+
if (kind === 'li') {
|
|
2223
|
+
await convertListItemAway(liveBlockEl, liRun, rec, target);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
// S2 Task 5: a non-list block BECOMES a li, so §4.3 rule 2's looseness
|
|
2227
|
+
// policy applies — eat the separator to an adjacent run of the same list
|
|
2228
|
+
// type, or the merged list goes LOOSE and every item of it degrades
|
|
2229
|
+
// read-only. Same rule, same helper the li → li path above uses.
|
|
2230
|
+
if (convertMd.targetIsList(target)) {
|
|
2231
|
+
await convertBlockIntoList(liveBlockEl, rec, kind, target);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
const src = lines.slice(rec.startLine - 1, rec.endLine);
|
|
2236
|
+
const stripped = convertMd.stripMarker(src, kind);
|
|
2237
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
2238
|
+
const newLines = convertMd.emitAs(stripped.content, target, {});
|
|
2239
|
+
|
|
2240
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2241
|
+
rec.startLine, rec.endLine, newLines.join('\n'));
|
|
2242
|
+
// Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
|
|
2243
|
+
// onto the undo stack either, so there is nothing to render or roll back.
|
|
2244
|
+
if (result.op === null) return;
|
|
2245
|
+
const prevLines = lines;
|
|
2246
|
+
lines = result.lines;
|
|
2247
|
+
const okRender = await safeRerenderAll();
|
|
2248
|
+
if (!okRender) {
|
|
2249
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Is the editor session switchAwayFrom() would resolve open on THIS block?
|
|
2254
|
+
// Both shapes count — the always-on WYSIWYG burst and the older raw-edit /
|
|
2255
|
+
// table-cell `activeEditor` — because resolveOpenSession() commits either.
|
|
2256
|
+
function ownsOpenSession(blockEl) {
|
|
2257
|
+
if (currentBurst && currentBurst.blockEl === blockEl) return true;
|
|
2258
|
+
if (activeEditor && activeEditor.blockEl === blockEl) return true;
|
|
2259
|
+
return false;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// The narrowed re-resolve for the case above: startLine and type must still
|
|
2263
|
+
// match, but the source is allowed to differ because our own switchAwayFrom()
|
|
2264
|
+
// just rewrote it. Deliberately NOT folded into reresolveBlockEl() — every
|
|
2265
|
+
// other caller of that function needs the fingerprint, and a shared helper
|
|
2266
|
+
// that sometimes skips it is the shape that lets a future call site act on a
|
|
2267
|
+
// block the user never pointed at.
|
|
2268
|
+
function reresolveBlockElAfterSelfCommit(identity) {
|
|
2269
|
+
if (!identity) return null;
|
|
2270
|
+
const at = blocks.find((b) => b.startLine === identity.startLine);
|
|
2271
|
+
if (!at || at.type !== identity.type) return null;
|
|
2272
|
+
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// S2 Task 6 — 複製 (§4.3).
|
|
2276
|
+
//
|
|
2277
|
+
// The copy is inserted after the block's ENTIRE SUBTREE, never after its own
|
|
2278
|
+
// line. The spec records the measurement and it reproduces here:
|
|
2279
|
+
// after the subtree '- a\n - a1\n- a\n- b\n'
|
|
2280
|
+
// -> items ['- a\n - a1\n', '- a\n', '- b'] (a keeps its child)
|
|
2281
|
+
// after a's own line '- a\n- a\n - a1\n- b\n'
|
|
2282
|
+
// -> items ['- a\n', '- a\n - a1\n', '- b'] (the COPY got a1)
|
|
2283
|
+
// Both lex cleanly and both are tight, so nothing but the item boundaries
|
|
2284
|
+
// tells them apart — which is why the runtime scenario asserts the raws.
|
|
2285
|
+
//
|
|
2286
|
+
// TWO commit paths, and which one each case takes was MEASURED against the
|
|
2287
|
+
// pure core, not reasoned from symmetry:
|
|
2288
|
+
//
|
|
2289
|
+
// * a NON-li block goes through commitBlockInsertion(), which IS this
|
|
2290
|
+
// operation and already owns the blank-line policy (see :170-179).
|
|
2291
|
+
// Measured on ['# Doc','','alpha',''] with body ['alpha']:
|
|
2292
|
+
// '# Doc\n\nalpha\n\nalpha\n'.
|
|
2293
|
+
// * a li does NOT — this is the trap. commitBlockInsertion() ALWAYS
|
|
2294
|
+
// inserts a leading blank line. Measured on ['# Doc','','- a','- b','']
|
|
2295
|
+
// with body ['- a'] it returns '# Doc\n\n- a\n\n- a\n\n- b\n', and
|
|
2296
|
+
// marked.lexer() reports that as ONE list with loose === true. Every item
|
|
2297
|
+
// of a loose list renders as <p>, serializeBlocks() pushes 'P' for each of
|
|
2298
|
+
// them (list-md.js:462) and the WHOLE run degrades read-only with no
|
|
2299
|
+
// banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
|
|
2300
|
+
// conversion. A li therefore duplicates through its own RUN's
|
|
2301
|
+
// re-serialization (duplicateListItem() below), which emits no blank at
|
|
2302
|
+
// all and re-runs §3.8's renumbering on the way.
|
|
2303
|
+
//
|
|
2304
|
+
// Neither path re-serializes the copy's CONTENT: the non-li path slices
|
|
2305
|
+
// `lines`, and the li path carries the clone through bystanderCarryOver()
|
|
2306
|
+
// under the ORIGINAL's block id, so list-md.js replays the file's own bytes
|
|
2307
|
+
// for it and only re-states the marker. Both keep a `~5px` a `~5px`.
|
|
2308
|
+
async function duplicateBlockViaMenu(blockEl) {
|
|
2309
|
+
if (!blockEl) return;
|
|
2310
|
+
const identity = captureBlockIdentity(blockEl);
|
|
2311
|
+
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
2312
|
+
// alive across the ⠿ press, so the commit that lands inside
|
|
2313
|
+
// switchAwayFrom() below can be a rewrite of the very block we are about
|
|
2314
|
+
// to duplicate — in which case reresolveBlockEl()'s SOURCE fingerprint is
|
|
2315
|
+
// guaranteed to miss, because WE are the reason the source changed. Same
|
|
2316
|
+
// narrowed recovery convertBlockViaMenu() uses, for the same reason; see
|
|
2317
|
+
// reresolveBlockElAfterSelfCommit().
|
|
2318
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
2319
|
+
const ok = await switchAwayFrom();
|
|
2320
|
+
if (!ok) return;
|
|
2321
|
+
let liveBlockEl = blockEl;
|
|
2322
|
+
if (!document.body.contains(blockEl)) {
|
|
2323
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2324
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2325
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2326
|
+
}
|
|
2327
|
+
// Same refusal deleteBlockViaGutter() and convertBlockViaMenu() make, for
|
|
2328
|
+
// the same reason: a block that owns no source line has an INVERTED range
|
|
2329
|
+
// (endLine === startLine - 1) and every commit helper handed one does
|
|
2330
|
+
// something plausible and wrong.
|
|
2331
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2332
|
+
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2333
|
+
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2334
|
+
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2335
|
+
|
|
2336
|
+
if (liveBlockEl.getAttribute('data-block-type') === 'li') {
|
|
2337
|
+
await duplicateListItem(liveBlockEl);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId,
|
|
2342
|
+
lines.slice(rec.startLine - 1, rec.endLine));
|
|
2343
|
+
if (result.op === null) return;
|
|
2344
|
+
const prevLines = lines;
|
|
2345
|
+
lines = result.lines;
|
|
2346
|
+
if (!(await safeRerenderAll())) {
|
|
2347
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// The li half of 複製. The copy is spliced into the run's own span and the
|
|
2352
|
+
// WHOLE span is re-serialized over the run's line range — one commitRangeEdit,
|
|
2353
|
+
// therefore one undo op (§4.3: 複製與刪除均為單一 undo), no leading blank, and
|
|
2354
|
+
// §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
|
|
2355
|
+
// gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
|
|
2356
|
+
// bravo').
|
|
2357
|
+
async function duplicateListItem(liEl) {
|
|
2358
|
+
const run = listRunOf(liEl);
|
|
2359
|
+
if (!run.length) return;
|
|
2360
|
+
// §4.3's run-wide gate — 轉換/複製/刪除/拖曳 each make this call for
|
|
2361
|
+
// themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
|
|
2362
|
+
// which is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2363
|
+
// descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
|
|
2364
|
+
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
2365
|
+
// TARGET while remaining a perfectly good bystander.
|
|
2366
|
+
if (!listRunSupportsStructuralEdit(run, liEl)) { refuseStructuralListEdit(); return; }
|
|
2367
|
+
// Captured BEFORE the copy enters the span. The copy carries the
|
|
2368
|
+
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
2369
|
+
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
2370
|
+
// resolve it to the original's record, and on a duplicate of the span's
|
|
2371
|
+
// LAST member that silently re-states the range's end line.
|
|
2372
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2373
|
+
if (!range) return;
|
|
2374
|
+
|
|
2375
|
+
// §4.3, measured: after the SUBTREE, not after the item's own line.
|
|
2376
|
+
// subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
|
|
2377
|
+
// following blocks at a STRICTLY greater indent — and listRunOf() already
|
|
2378
|
+
// covers every one of them, so the insertion point is always inside `run`.
|
|
2379
|
+
const subtree = subtreeBlocksAfter(liEl, Number(liEl.getAttribute('data-indent')) || 0);
|
|
2380
|
+
const lastEl = subtree.length ? subtree[subtree.length - 1] : liEl;
|
|
2381
|
+
const at = run.indexOf(lastEl);
|
|
2382
|
+
if (at < 0) return;
|
|
2383
|
+
|
|
2384
|
+
const copy = liEl.cloneNode(true);
|
|
2385
|
+
// `data-list-start` is the ONLY carrier of "marked's lexer opened a new
|
|
2386
|
+
// list token here" (§3.8 rule (d)) and serializeBlocks() resets the
|
|
2387
|
+
// ordinal counter on it. A copy is never a token boundary — it sits inside
|
|
2388
|
+
// the run it was cloned from — so a clone that kept the attribute would
|
|
2389
|
+
// restart the numbering: duplicating the first item of '1. alpha / 2.
|
|
2390
|
+
// bravo' emits '1. alpha / 1. alpha / 2. bravo'.
|
|
2391
|
+
copy.removeAttribute('data-list-start');
|
|
2392
|
+
const span = run.slice(0, at + 1).concat([copy], run.slice(at + 1));
|
|
2393
|
+
mutateListRun(() => {
|
|
2394
|
+
lastEl.parentNode.insertBefore(copy, lastEl.nextSibling);
|
|
2395
|
+
});
|
|
2396
|
+
// No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
|
|
2397
|
+
// DOM, the copy included. The map is what keeps both lines byte-identical
|
|
2398
|
+
// to the file — dropping it entirely re-serializes them through
|
|
2399
|
+
// inline-md.js's escapeText() and a '~5px' comes back '\~5px' (measured;
|
|
2400
|
+
// the 'the copy is not re-escaped' scenario is what notices).
|
|
2401
|
+
//
|
|
2402
|
+
// ⚠ MEASURED, and worth stating because it is NOT the usual contract:
|
|
2403
|
+
// passing `liEl` here would be INERT, unlike at every other call site.
|
|
2404
|
+
// bystanderCarryOver() keys the map on the block ID, and the copy carries
|
|
2405
|
+
// the ORIGINAL's id — so the copy's own pass re-adds the very entry the
|
|
2406
|
+
// exclusion just skipped. The argument is omitted because it is wrong in
|
|
2407
|
+
// principle (nothing was mutated), not because a test would catch it.
|
|
2408
|
+
//
|
|
2409
|
+
// That shared id is also what makes ONE map entry serve both lines, while
|
|
2410
|
+
// list-md.js re-states each line's marker from that element's OWN
|
|
2411
|
+
// attributes — which is the §3.8 renumbering, and which is also how the
|
|
2412
|
+
// copy keeps its 型態 / 縮排 / 勾選狀態.
|
|
2413
|
+
await commitListStructure(span, null, false,
|
|
2414
|
+
{ presetRange: range, carryOver: bystanderCarryOver(span) });
|
|
2415
|
+
}
|
|
1403
2416
|
// Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
|
|
1404
2417
|
// — unchanged, see its own comment — to any block type, not just an
|
|
1405
2418
|
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
1406
2419
|
// precondition as insertBlockBelow() above.
|
|
1407
2420
|
async function deleteBlockViaGutter(blockEl) {
|
|
1408
2421
|
if (!blockEl) return;
|
|
1409
|
-
|
|
2422
|
+
// T7: same startLine + source-fingerprint recovery as insertBlockBelow()
|
|
2423
|
+
// above, and the stake here is higher — an id shift used to make this
|
|
2424
|
+
// delete a DIFFERENT block's lines, with the ⠿ menu the user pressed
|
|
2425
|
+
// pointing at a block that survived.
|
|
2426
|
+
const identity = captureBlockIdentity(blockEl);
|
|
2427
|
+
// S2 Task 6: the SAME hole convertBlockViaMenu() closed in Task 2, which
|
|
2428
|
+
// this path never got. Finding 5a's mousedown preventDefault() keeps a
|
|
2429
|
+
// dirty burst alive across the ⠿ press, so the commit that lands inside
|
|
2430
|
+
// switchAwayFrom() below can be a rewrite of the very block being deleted
|
|
2431
|
+
// — and reresolveBlockEl()'s fingerprint is that block's SOURCE, so it is
|
|
2432
|
+
// guaranteed to MISS, because WE are the reason the source changed.
|
|
2433
|
+
// Measured before this line existed: edit a paragraph, press ⠿ without
|
|
2434
|
+
// blurring, 刪除 — the gesture was dropped with '文件已更新,請重試這個操作'
|
|
2435
|
+
// and the block stayed on screen. The narrowed re-resolve (startLine +
|
|
2436
|
+
// type, no fingerprint) is used ONLY when the session that just committed
|
|
2437
|
+
// was this block's OWN; reresolveBlockEl() keeps its fingerprint for
|
|
2438
|
+
// everybody else.
|
|
2439
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
1410
2440
|
const ok = await switchAwayFrom();
|
|
1411
2441
|
if (!ok) return;
|
|
1412
2442
|
let liveBlockEl = blockEl;
|
|
1413
2443
|
if (!document.body.contains(blockEl)) {
|
|
1414
|
-
liveBlockEl =
|
|
1415
|
-
|
|
2444
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2445
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2446
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
1416
2447
|
}
|
|
1417
2448
|
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
1418
2449
|
const block = blocks.find((b) => b.id === liveBlockId);
|
|
1419
2450
|
if (!block) return;
|
|
2451
|
+
// Task 4 fix round 1 (Critical): refuse a block that owns no source line.
|
|
2452
|
+
// Its range is INVERTED (endLine === startLine - 1 — see blockOwnsNoLine()),
|
|
2453
|
+
// and commitListBlockRemoval() -> commitRangeRemoval() does not guard
|
|
2454
|
+
// `endLine >= startLine`: with sl=5, el=4 the blank-line absorption reads
|
|
2455
|
+
// state.lines[sl - 2], which for an inverted range is a blank line
|
|
2456
|
+
// belonging to a DIFFERENT block, finds it blank, and deletes it. Nothing
|
|
2457
|
+
// visible happens — no error, no banner, the .ed-block count is unchanged
|
|
2458
|
+
// — but the file loses a separator (measured: '# Doc\n\n- a\n\n- - b\n'
|
|
2459
|
+
// -> '# Doc\n\n- a\n- - b\n').
|
|
2460
|
+
//
|
|
2461
|
+
// Same predicate canWysiwygForLi() already refuses on, so "cannot be
|
|
2462
|
+
// armed" and "cannot be deleted" stay one decision. The ⠿ itself is NOT
|
|
2463
|
+
// gated on it: Task 4 requires every block to have a handle, and hiding it
|
|
2464
|
+
// would trade that requirement for a delete-path bug. Re-checked against
|
|
2465
|
+
// the LIVE block, after switchAwayFrom()'s possible re-render renumbered
|
|
2466
|
+
// the ids.
|
|
2467
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2468
|
+
// Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
|
|
2469
|
+
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
2470
|
+
// below corrupts a list three separate ways — see
|
|
2471
|
+
// deleteListItemViaGutter() for the measurements and the routing.
|
|
2472
|
+
if (liveBlockEl.getAttribute('data-block-type') === 'li') {
|
|
2473
|
+
await deleteListItemViaGutter(liveBlockEl);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
1420
2476
|
const result = commitListBlockRemoval({ lines, blocks, stack }, liveBlockId);
|
|
1421
2477
|
const prevLines = lines;
|
|
1422
2478
|
lines = result.lines;
|
|
1423
2479
|
const okRender = await safeRerenderAll();
|
|
1424
2480
|
if (!okRender) {
|
|
1425
|
-
|
|
1426
|
-
|
|
2481
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
|
|
2486
|
+
//
|
|
2487
|
+
// Up to S1 this path did not exist: armEditables() returned before the
|
|
2488
|
+
// gutter chrome for a li, so the menu (and therefore its 刪除) was
|
|
2489
|
+
// unreachable on one. S1 gives every block a ⠿, which connected the
|
|
2490
|
+
// block-type-agnostic commitListBlockRemoval() to the most natural gesture
|
|
2491
|
+
// in the new UI — and that function deletes ONE BLOCK'S LINE RANGE, which is
|
|
2492
|
+
// the wrong unit for a list in three separate ways, all measured with a real
|
|
2493
|
+
// gesture plus Ctrl+S:
|
|
2494
|
+
//
|
|
2495
|
+
// * its blank-line absorption is correct for a standalone block and wrong
|
|
2496
|
+
// for a run member: the blank ABOVE the run still separates the
|
|
2497
|
+
// SURVIVORS from whatever precedes them. 'Para.\n\n1. a\n2. b\n3. c\n'
|
|
2498
|
+
// came back 'Para.\n2. b\n3. c\n' — one paragraph, three items gone,
|
|
2499
|
+
// no banner.
|
|
2500
|
+
// * no §3.4 clamp, so a child outlives its parent at an indent nothing
|
|
2501
|
+
// anchors: '# T\n\n- a\n - deep\n- b\n' left ' - deep' four
|
|
2502
|
+
// columns after a heading, i.e. an INDENTED CODE BLOCK.
|
|
2503
|
+
// * no re-serialization of the survivors, so an ordered run kept its old
|
|
2504
|
+
// ordinals on disk ('2. b / 3. c') while the CSS counter showed 1,2 —
|
|
2505
|
+
// the file and the screen disagreeing until somebody types in that run.
|
|
2506
|
+
//
|
|
2507
|
+
// The sequence is convertEmptyTopLevelLiToParagraph()'s, not a new one:
|
|
2508
|
+
// capture the span's range BEFORE mutating (removing the last item leaves
|
|
2509
|
+
// commitListStructure() nothing to derive it from), clamp, remove, then
|
|
2510
|
+
// commit the re-serialized survivors over that range with every one of them
|
|
2511
|
+
// carried over verbatim — nothing here rewrites any survivor's CONTENT, only
|
|
2512
|
+
// its marker and its leading columns.
|
|
2513
|
+
async function deleteListItemViaGutter(liEl) {
|
|
2514
|
+
const run = listRunOf(liEl);
|
|
2515
|
+
if (!run.length) return;
|
|
2516
|
+
// §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
|
|
2517
|
+
// exactly what listRunOf() returns (the outermost run PLUS every
|
|
2518
|
+
// descendant of its members), so the deeper runs this delete is about to
|
|
2519
|
+
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
2520
|
+
// column-only: it removes the target's lines outright, so a multi-line
|
|
2521
|
+
// target refuses per §4.1.
|
|
2522
|
+
if (!listRunSupportsStructuralEdit(run, liEl)) { refuseStructuralListEdit(); return; }
|
|
2523
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2524
|
+
if (!range) return;
|
|
2525
|
+
const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
|
|
2526
|
+
const survivors = run.filter((el) => el !== liEl);
|
|
2527
|
+
mutateListRun(() => {
|
|
2528
|
+
// Clamp FIRST, while `liEl` is still in the span: `{ removed: true }` is
|
|
2529
|
+
// what tells the pure function that this block can no longer anchor
|
|
2530
|
+
// anything, and rule 2's scope is measured from its position.
|
|
2531
|
+
applyIndentClamp(run, liEl, oldIndent, { removed: true });
|
|
2532
|
+
removeListItem(liEl);
|
|
2533
|
+
});
|
|
2534
|
+
// No `mutatedEl`: the deleted block is not among the survivors, and every
|
|
2535
|
+
// survivor's own bytes are exactly what the file already holds. The marker
|
|
2536
|
+
// is re-stated by the serializer regardless of the carry-over, which is
|
|
2537
|
+
// what renumbers the run (§3.8) and applies the clamped indent.
|
|
2538
|
+
await commitListStructure(survivors, null, false,
|
|
2539
|
+
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// S2 Task 3 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a li.
|
|
2543
|
+
//
|
|
2544
|
+
// The block STAYS a li, which is what makes this the easy list shape: the
|
|
2545
|
+
// run stays a run, so nothing here has to reach for convert-md.js at all.
|
|
2546
|
+
// The two attributes are flipped in the DOM and the whole span goes back
|
|
2547
|
+
// through commitListStructure() exactly like every other structural list op
|
|
2548
|
+
// — which is also what re-runs §3.8's renumbering (a type change splits the
|
|
2549
|
+
// run at this item, so both halves restart at 1) and §3.4's marker-width
|
|
2550
|
+
// stack (a child under a '1. ' parent moves from column 2 to column 3).
|
|
2551
|
+
//
|
|
2552
|
+
// `run` and the §4.3 gate are the CALLER's (convertBlockViaMenu): the gate
|
|
2553
|
+
// has to sit ahead of every other refusal so a multi-line li reports §4.1's
|
|
2554
|
+
// banner, and re-deriving the run here would walk `allBlockEls()` twice.
|
|
2555
|
+
//
|
|
2556
|
+
// MEASURED, and it contradicts the plan, which pinned blank lines either
|
|
2557
|
+
// side of the converted item: none are emitted and none are needed.
|
|
2558
|
+
// `marked.lexer('- alpha\n1. bravo\n- charlie\n')` already returns THREE
|
|
2559
|
+
// list tokens — a marker-type change interrupts a list on its own. §4.3
|
|
2560
|
+
// rule 1's blank line is about li → NON-LIST (Task 4), where a bare
|
|
2561
|
+
// paragraph line really would be swallowed as a lazy continuation.
|
|
2562
|
+
//
|
|
2563
|
+
// `data-list-type` and `data-task` are §4.1's two ORTHOGONAL axes, so 待辦
|
|
2564
|
+
// 清單 is 'ul' + task and switching to 項目符號列表 removes the checkbox
|
|
2565
|
+
// rather than merely unchecking it — a plain bullet has nowhere in the
|
|
2566
|
+
// markdown to store checkedness.
|
|
2567
|
+
// ── §4.3 rule 2 (the looseness trap), in its 2026-08-30 revised form ──────
|
|
2568
|
+
//
|
|
2569
|
+
// MEASURED, twice, and the second measurement is what the revision is about:
|
|
2570
|
+
// marked.lexer('- a\n- b\n- c\n') → ONE list, loose === false
|
|
2571
|
+
// marked.lexer('- a\n- b\n\n- c\n') → ONE list, loose === TRUE
|
|
2572
|
+
// A blank line between two lists of the SAME marker type does not separate
|
|
2573
|
+
// them; it makes the single list they form LOOSE. Every item of a loose list
|
|
2574
|
+
// renders as `<p>…</p>`, serializeBlocks() reports 'P' for each of them
|
|
2575
|
+
// (list-md.js:56-70 documents the ruling, :462 is the push) and the whole run
|
|
2576
|
+
// degrades read-only — with NO banner, because nothing refused anything.
|
|
2577
|
+
//
|
|
2578
|
+
// The spec's original wording keyed the rule on 「來源是非清單」. That is
|
|
2579
|
+
// wrong, and the counter-example was measured in the live editor during S2
|
|
2580
|
+
// Task 3:
|
|
2581
|
+
// start '# Doc\n\n- a\n\n1. b\n' → list|space|list, BOTH tight
|
|
2582
|
+
// gesture 轉換成 › 項目符號列表 on `b`
|
|
2583
|
+
// bytes '# Doc\n\n- a\n\n- b\n' → ONE list, loose === true
|
|
2584
|
+
// after every structural gesture on that run refuses with §4.1's banner
|
|
2585
|
+
// One li → li conversion froze a run the user could no longer restructure.
|
|
2586
|
+
// The ruling therefore keys on 「轉換結果是 li」: whatever the source was, if
|
|
2587
|
+
// the RESULT is a li, the separator to a same-type neighbour must be eaten.
|
|
2588
|
+
//
|
|
2589
|
+
// Two consequences that are not obvious:
|
|
2590
|
+
//
|
|
2591
|
+
// 1. The blank line being eaten lies OUTSIDE listRunOf()'s span — it belongs
|
|
2592
|
+
// BETWEEN two runs, to neither. So the commit range has to be widened
|
|
2593
|
+
// past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
|
|
2594
|
+
// two places where that happens (§3.4's 2026-08-30 erratum); the other is
|
|
2595
|
+
// §4.3 rule 1's edge blanks in convertListItemAway() above.
|
|
2596
|
+
//
|
|
2597
|
+
// 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
|
|
2598
|
+
// into a healthy one freezes the healthy one too — and declining to merge
|
|
2599
|
+
// is no escape, because once the marker types match, markdown merges the
|
|
2600
|
+
// two whether or not the separator survives (it just goes loose instead).
|
|
2601
|
+
// The only correct answer there is to refuse the whole gesture, which is
|
|
2602
|
+
// what `ok: false` means.
|
|
2603
|
+
//
|
|
2604
|
+
// ⚠ The question is asked about the neighbour's RUN, not about the
|
|
2605
|
+
// neighbour BLOCK — and this contradicts the plan's Task 5 sketch, which
|
|
2606
|
+
// tests `previousBlockEl`'s own data-indent/data-list-type. MEASURED:
|
|
2607
|
+
// '- alpha\n - beta\n\n- gamma\n' → ONE list, loose === true
|
|
2608
|
+
// The block above the separator is `beta` at indent 1, so the sketch's
|
|
2609
|
+
// predicate says "no merge" and commits exactly those degrading bytes. The
|
|
2610
|
+
// list `gamma` actually joins is ALPHA's, and looseness is a property of the
|
|
2611
|
+
// whole list token — so the comparison must be against listRunOf(neighbour)'s
|
|
2612
|
+
// HEAD, which is the run's top-level identity.
|
|
2613
|
+
//
|
|
2614
|
+
// `range` is the commit range as the caller's own machinery derived it;
|
|
2615
|
+
// `spanEls` the block span that range covers (used only to find the
|
|
2616
|
+
// neighbouring blocks); `headAttrs` / `tailAttrs` the {listType, indent} the
|
|
2617
|
+
// span's first and last TOP-LEVEL lines will carry AFTER the conversion —
|
|
2618
|
+
// which is why they are passed in rather than read here: for li → li this
|
|
2619
|
+
// runs BEFORE the DOM mutation, so a refusal never has a half-mutated run to
|
|
2620
|
+
// undo.
|
|
2621
|
+
function widenRangeForListMerge(range, spanEls, headAttrs, tailAttrs) {
|
|
2622
|
+
const all = allBlockEls();
|
|
2623
|
+
const first = spanEls[0];
|
|
2624
|
+
const last = spanEls[spanEls.length - 1];
|
|
2625
|
+
const i = all.indexOf(first);
|
|
2626
|
+
const j = all.indexOf(last);
|
|
2627
|
+
let startLine = range.startLine;
|
|
2628
|
+
let endLine = range.endLine;
|
|
2629
|
+
const sides = [
|
|
2630
|
+
{ el: i > 0 ? all[i - 1] : null, attrs: headAttrs, back: true },
|
|
2631
|
+
{ el: (j >= 0 && j + 1 < all.length) ? all[j + 1] : null, attrs: tailAttrs, back: false },
|
|
2632
|
+
];
|
|
2633
|
+
for (let k = 0; k < sides.length; k++) {
|
|
2634
|
+
const side = sides[k];
|
|
2635
|
+
if (!side.el || !side.attrs) continue;
|
|
2636
|
+
if (side.el.getAttribute('data-block-type') !== 'li') continue;
|
|
2637
|
+
// The neighbour's OWN outermost run — see the ⚠ above.
|
|
2638
|
+
const nrun = listRunOf(side.el);
|
|
2639
|
+
if (!nrun.length) continue;
|
|
2640
|
+
const head = nrun[0];
|
|
2641
|
+
if ((Number(head.getAttribute('data-indent')) || 0) !== side.attrs.indent) continue;
|
|
2642
|
+
const headType = head.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
2643
|
+
if (headType !== side.attrs.listType) continue;
|
|
2644
|
+
// Every line between two adjacent blocks is blank by construction
|
|
2645
|
+
// (buildBlockMap() strips a token's trailing newlines, so no block ever
|
|
2646
|
+
// owns a separator). Eating ALL of them is also 「正規化連續空行」:
|
|
2647
|
+
// measured, '- a\n\n\n- b\n' is still ONE loose list, so stopping after
|
|
2648
|
+
// one blank would leave the degrade in place.
|
|
2649
|
+
let moved = false;
|
|
2650
|
+
if (side.back) {
|
|
2651
|
+
while (startLine >= 2 && String(lines[startLine - 2]).trim() === '') {
|
|
2652
|
+
startLine -= 1; moved = true;
|
|
2653
|
+
}
|
|
2654
|
+
} else {
|
|
2655
|
+
while (endLine < lines.length && String(lines[endLine]).trim() === '') {
|
|
2656
|
+
endLine += 1; moved = true;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
// No separator between us and it: they are already two runs that
|
|
2660
|
+
// markdown keeps apart for a reason this rule does not touch (a
|
|
2661
|
+
// delimiter change, `- a` / `* b`). Nothing to eat, nothing to gate.
|
|
2662
|
+
if (!moved) continue;
|
|
2663
|
+
// Consequence 2. `columnOnly` is the honest option here: this run's
|
|
2664
|
+
// bytes are not being rewritten AT ALL (it sits entirely outside the
|
|
2665
|
+
// commit range), so the only question worth asking of it is the
|
|
2666
|
+
// `unsupported` one — and a hard-wrapped bystander li in there must not
|
|
2667
|
+
// veto the merge, exactly as §4.1 keeps it legal everywhere else.
|
|
2668
|
+
if (!listRunSupportsStructuralEdit(nrun, null, { columnOnly: true })) {
|
|
2669
|
+
return { startLine: range.startLine, endLine: range.endLine, ok: false };
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
return { startLine: startLine, endLine: endLine, ok: true };
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// The {listType, indent} a span member will carry once `liEl` has become
|
|
2676
|
+
// `attrs`. Everything except the converted item keeps what it already has.
|
|
2677
|
+
function postConvertLiAttrs(el, liEl, attrs) {
|
|
2678
|
+
return {
|
|
2679
|
+
listType: el === liEl
|
|
2680
|
+
? attrs.listType
|
|
2681
|
+
: (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
|
|
2682
|
+
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
async function convertListItemType(liEl, run, target) {
|
|
2687
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2688
|
+
if (!range) return;
|
|
2689
|
+
const attrs = convertMd.listAttrsFor(target);
|
|
2690
|
+
if (!attrs) return;
|
|
2691
|
+
// §4.3 rule 2, in its revised form: the ruling keys on 「轉換結果是 li」,
|
|
2692
|
+
// so it applies HERE too, not only to the 非清單 → 清單 path below. This
|
|
2693
|
+
// is the S2 Task 3 defect — '- a' + blank + '1. b', both tight, and one
|
|
2694
|
+
// 轉換成 › 項目符號列表 on `b` merged them into one LOOSE list that froze
|
|
2695
|
+
// read-only with no banner. See widenRangeForListMerge()'s own note.
|
|
2696
|
+
//
|
|
2697
|
+
// The head/tail of the span are compared, not the converted item: a
|
|
2698
|
+
// conversion in the MIDDLE of a run leaves the run's outer lines alone, and
|
|
2699
|
+
// it is those that abut the separators. `tailEl` is the last member at the
|
|
2700
|
+
// span's TOP-LEVEL indent — listRunOf() includes descendants, and a nested
|
|
2701
|
+
// trailing item is not what the following list token would merge with.
|
|
2702
|
+
// Computed BEFORE mutateListRun() so a refusal has nothing to undo.
|
|
2703
|
+
const headEl = run[0];
|
|
2704
|
+
const headIndent = Number(headEl.getAttribute('data-indent')) || 0;
|
|
2705
|
+
let tailEl = headEl;
|
|
2706
|
+
run.forEach((el) => {
|
|
2707
|
+
if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
|
|
2708
|
+
});
|
|
2709
|
+
const merged = widenRangeForListMerge(range, run,
|
|
2710
|
+
postConvertLiAttrs(headEl, liEl, attrs), postConvertLiAttrs(tailEl, liEl, attrs));
|
|
2711
|
+
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
2712
|
+
range.startLine = merged.startLine;
|
|
2713
|
+
range.endLine = merged.endLine;
|
|
2714
|
+
mutateListRun(() => {
|
|
2715
|
+
liEl.setAttribute('data-list-type', attrs.listType);
|
|
2716
|
+
liEl.setAttribute('data-task', attrs.task ? '1' : '0');
|
|
2717
|
+
const box = liCheckEl(liEl);
|
|
2718
|
+
if (attrs.task) {
|
|
2719
|
+
// Insert BEFORE the surface: §4.1 fixes the child order as
|
|
2720
|
+
// marker → check → text, and list-md.js's firstChildWithClass() plus
|
|
2721
|
+
// the delegated checkbox-toggle listener both assume it.
|
|
2722
|
+
if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
|
|
2723
|
+
} else if (box) {
|
|
2724
|
+
box.remove();
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
// NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
|
|
2728
|
+
// sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
|
|
2729
|
+
// EXCLUDES `mutatedEl` from the replay map, so naming the converted item
|
|
2730
|
+
// is what sends ITS content back through inline-md.js's escapeText().
|
|
2731
|
+
// Measured: serializeInline('~5px') === '\~5px', and the runtime scenario
|
|
2732
|
+
// 'a list-type change never re-escapes the item’s own content' failed
|
|
2733
|
+
// exactly that way before this line lost its second argument. Nothing
|
|
2734
|
+
// here rewrote the item's CONTENT — only two attributes and a checkbox
|
|
2735
|
+
// span, none of which the serializer reads from `.ed-li-text` — so its
|
|
2736
|
+
// bytes belong to the file, same as every other member of the run.
|
|
2737
|
+
// Carrying it is free: list-md.js emits `head + carriedSplit.content` for
|
|
2738
|
+
// a carried line, i.e. it re-states the marker from the NEW attributes,
|
|
2739
|
+
// and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
|
|
2740
|
+
// carried source. That is what makes '- [x] alpha' → '- alpha' work.
|
|
2741
|
+
await commitListStructure(run, null, false,
|
|
2742
|
+
{ presetRange: range, carryOver: bystanderCarryOver(run) });
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// The `data-block-type` a conversion target will carry once it is committed.
|
|
2746
|
+
// Only ever handed to indent-clamp's `operatedBecomes`, whose one question
|
|
2747
|
+
// is "is this still a li?" — but naming the real type keeps the call honest
|
|
2748
|
+
// if the pure function ever grows a second question.
|
|
2749
|
+
function convertedBlockType(target) {
|
|
2750
|
+
if (/^h[1-6]$/.test(target)) return 'heading';
|
|
2751
|
+
if (target === 'quote') return 'blockquote';
|
|
2752
|
+
if (target === 'code') return 'code';
|
|
2753
|
+
return 'paragraph';
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// S2 Task 4 — 轉換成 › 文字 / 標題 N / 程式碼 / 引用 on a li (§4.3 rule 1).
|
|
2757
|
+
//
|
|
2758
|
+
// The item LEAVES the run, so the run's own line range is rebuilt in three
|
|
2759
|
+
// pieces: the survivors before it, the converted block's own lines (read
|
|
2760
|
+
// from `lines`, never re-serialized — that is what keeps a `~5px` a `~5px`),
|
|
2761
|
+
// and the survivors after it. §3.8's renumbering falls out of re-serializing
|
|
2762
|
+
// each surviving half on its own; §4.3 rule 1's blank lines are the '\n\n'
|
|
2763
|
+
// joins between the pieces.
|
|
2764
|
+
//
|
|
2765
|
+
// `run` and the §4.3 run-wide gate are the CALLER's (convertBlockViaMenu),
|
|
2766
|
+
// for the reason spelled out there: the gate must sit ahead of every other
|
|
2767
|
+
// refusal so a multi-line li reports §4.1's banner and not stripMarker()'s
|
|
2768
|
+
// narrower one.
|
|
2769
|
+
//
|
|
2770
|
+
// Deliberately NOT re-checked here: `serializeBlocks().unsupported` on the
|
|
2771
|
+
// two halves. The gate above already serialized the WHOLE run through
|
|
2772
|
+
// listRunSupportsStructuralEdit(), and `unsupported` is a per-block fact, so
|
|
2773
|
+
// splitting the span cannot add a name. A naive `unsupported.length > 0`
|
|
2774
|
+
// re-check is worse than redundant — it refuses a HARD-WRAPPED bystander,
|
|
2775
|
+
// which §4.1 explicitly keeps legal (MULTILINE is filtered out of the
|
|
2776
|
+
// run-wide veto and re-checked against the TARGET's line range only).
|
|
2777
|
+
// Measured; the 'a multi-line bystander is replayed, not refused' scenario
|
|
2778
|
+
// is what notices.
|
|
2779
|
+
async function convertListItemAway(liEl, run, rec, target) {
|
|
2780
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2781
|
+
if (!range) return;
|
|
2782
|
+
const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
|
|
2783
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
2784
|
+
const convertedLines = convertMd.emitAs(stripped.content, target, {});
|
|
2785
|
+
|
|
2786
|
+
const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
|
|
2787
|
+
const idx = run.indexOf(liEl);
|
|
2788
|
+
const before = run.slice(0, idx);
|
|
2789
|
+
const after = run.slice(idx + 1);
|
|
2790
|
+
|
|
2791
|
+
// §3.4, and the FIRST production caller of the pure clamp's
|
|
2792
|
+
// `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
|
|
2793
|
+
// option is what tells clampIndents() that it can no longer anchor
|
|
2794
|
+
// anything, and rule 2's scope is measured from its position — and it is
|
|
2795
|
+
// NOT removed from the DOM: nothing below serializes it (its bytes come
|
|
2796
|
+
// from convert-md.js), commitRangeEdit() + safeRerenderAll() rebuild the
|
|
2797
|
+
// whole document from markdown anyway, and leaving it there means a
|
|
2798
|
+
// FAILED render shows the pre-conversion item rather than a hole.
|
|
2799
|
+
// mutateListRun() is still the wrapper, for its finally: `data-indent`
|
|
2800
|
+
// just moved on the survivors, so `data-run-start` (the ordered counter's
|
|
2801
|
+
// CSS reset) is stale for the length of the render round trip.
|
|
2802
|
+
//
|
|
2803
|
+
// ⚠ MEASURED, and it contradicts the plan's step 7: on the plan's own
|
|
2804
|
+
// '- alpha / (2sp)- child / (4sp)- grandchild' fixture this clamp is a
|
|
2805
|
+
// NO-OP on the emitted bytes. serializeBlocks() rebuilds its marker-width
|
|
2806
|
+
// stack from EMPTY for each span it is given (list-md.js:502,
|
|
2807
|
+
// `widths.slice(0, indent)`), so the first block of the `after` half
|
|
2808
|
+
// always emits at column 0 whatever its data-indent says — which is
|
|
2809
|
+
// exactly what the clamp would have done to it. The clamp earns its place
|
|
2810
|
+
// one shape further out: when the scope holds TWO segments (§3.4 rule 3),
|
|
2811
|
+
// their deltas differ and the width stack cannot derive that on its own.
|
|
2812
|
+
// The 'the §3.4 segment deltas survive the split commit' scenario is that
|
|
2813
|
+
// shape, and it is the one that goes red without this option.
|
|
2814
|
+
mutateListRun(() => {
|
|
2815
|
+
applyIndentClamp(run, liEl, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
// No `mutatedEl`: the converted block is in neither half, and every
|
|
2819
|
+
// survivor's bytes are exactly what the file already holds. Naming a block
|
|
2820
|
+
// here EXCLUDES it from the replay map, which is what sends its content
|
|
2821
|
+
// back through escapeText() — see convertListItemType()'s note.
|
|
2822
|
+
const carry = bystanderCarryOver(before.concat(after));
|
|
2823
|
+
const pieces = [];
|
|
2824
|
+
if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
|
|
2825
|
+
pieces.push(convertedLines.join('\n'));
|
|
2826
|
+
if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
|
|
2827
|
+
let md = pieces.join('\n\n');
|
|
2828
|
+
|
|
2829
|
+
// §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
|
|
2830
|
+
// above already separate the pieces; outside it, the neighbouring line
|
|
2831
|
+
// belongs to another block and may be a li of an ADJACENT run (a
|
|
2832
|
+
// list-type change splits a run without a blank line — measured in Task
|
|
2833
|
+
// 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
|
|
2834
|
+
// added only when the neighbour is not already blank, which is also what
|
|
2835
|
+
// 「正規化連續空行」 amounts to here: no double separator is ever created.
|
|
2836
|
+
if (!before.length && range.startLine > 1 &&
|
|
2837
|
+
String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
|
|
2838
|
+
if (!after.length && range.endLine < lines.length &&
|
|
2839
|
+
String(lines[range.endLine]).trim() !== '') md = md + '\n';
|
|
2840
|
+
|
|
2841
|
+
const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
|
|
2842
|
+
if (result.op === null) return;
|
|
2843
|
+
const prevLines = lines;
|
|
2844
|
+
lines = result.lines;
|
|
2845
|
+
if (!(await safeRerenderAll())) {
|
|
2846
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
|
|
2851
|
+
// NOT a list item (§4.3 rule 2, the return leg of rule 1).
|
|
2852
|
+
//
|
|
2853
|
+
// Line-level like every other conversion: the source comes from `lines`, so
|
|
2854
|
+
// the content is never re-serialized and a `~5px` stays a `~5px`. The block
|
|
2855
|
+
// owns its own lines and nothing else is re-emitted, so there is no run to
|
|
2856
|
+
// serialize and no carryOver to build — the two neighbouring runs are
|
|
2857
|
+
// deliberately left byte-untouched, and the ONLY thing that leaves the
|
|
2858
|
+
// block's own range is the blank separator rule 2 eats.
|
|
2859
|
+
//
|
|
2860
|
+
// No §4.1 run-wide gate on the way in: the source is not a li, so it belongs
|
|
2861
|
+
// to no run. The gate that DOES apply is the one inside
|
|
2862
|
+
// widenRangeForListMerge(), on whichever neighbouring run this block is
|
|
2863
|
+
// about to merge into.
|
|
2864
|
+
async function convertBlockIntoList(blockEl, rec, kind, target) {
|
|
2865
|
+
const attrs = convertMd.listAttrsFor(target);
|
|
2866
|
+
if (!attrs) return;
|
|
2867
|
+
const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), kind);
|
|
2868
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
2869
|
+
const newLines = convertMd.emitAs(stripped.content, target, {});
|
|
2870
|
+
|
|
2871
|
+
// emitAs() puts a list target at column 0 with no indent prefix, so the
|
|
2872
|
+
// block's post-conversion identity is (target list type, indent 0) on both
|
|
2873
|
+
// edges — it emits one item, however many physical lines that item spans.
|
|
2874
|
+
const self = { listType: attrs.listType, indent: 0 };
|
|
2875
|
+
const merged = widenRangeForListMerge(
|
|
2876
|
+
{ startLine: rec.startLine, endLine: rec.endLine }, [blockEl], self, self);
|
|
2877
|
+
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
2878
|
+
|
|
2879
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2880
|
+
merged.startLine, merged.endLine, newLines.join('\n'));
|
|
2881
|
+
if (result.op === null) return;
|
|
2882
|
+
const prevLines = lines;
|
|
2883
|
+
lines = result.lines;
|
|
2884
|
+
if (!(await safeRerenderAll())) {
|
|
2885
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1427
2886
|
}
|
|
1428
2887
|
}
|
|
1429
2888
|
|
|
2889
|
+
// The `.ed-li-check` chrome for a li that has just BECOME a task item.
|
|
2890
|
+
//
|
|
2891
|
+
// ⚠ This markup must stay byte-identical to the renderer's, which builds the
|
|
2892
|
+
// same span from a template literal at lib/md2doc.js:287-289:
|
|
2893
|
+
// <span class="ed-li-check" data-checked="0" role="checkbox"
|
|
2894
|
+
// aria-checked="false"></span>
|
|
2895
|
+
// Attribute ORDER matters as well as content: `bystanderCarryOver()` and the
|
|
2896
|
+
// burst baseline both compare innerHTML strings, and the very next successful
|
|
2897
|
+
// render replaces this element with the renderer's own — so a mismatch would
|
|
2898
|
+
// show up as a spurious diff for exactly one commit round trip. A new
|
|
2899
|
+
// task item is always unchecked (nothing in a `- alpha` line says otherwise).
|
|
2900
|
+
//
|
|
2901
|
+
// It deliberately carries NO click handler: the toggle is a delegated
|
|
2902
|
+
// listener on `.content` that resolves via closest('.ed-li-check').
|
|
2903
|
+
// It adds no NEW element type either — 'ed-li-check' is already in
|
|
2904
|
+
// list-md.js's closed LI_CHROME allowlist, so serializeBlocks() keeps
|
|
2905
|
+
// skipping it instead of reporting SPAN as unsupported.
|
|
2906
|
+
function buildLiCheckbox() {
|
|
2907
|
+
const box = document.createElement('span');
|
|
2908
|
+
box.className = 'ed-li-check';
|
|
2909
|
+
box.setAttribute('data-checked', '0');
|
|
2910
|
+
box.setAttribute('role', 'checkbox');
|
|
2911
|
+
box.setAttribute('aria-checked', 'false');
|
|
2912
|
+
return box;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
1430
2915
|
// The ⠿ menu's "MD 原始碼" escape hatch: discards (never commits) any
|
|
1431
2916
|
// in-progress burst on THIS block — same "throw away my WYSIWYG edits,
|
|
1432
2917
|
// switch to raw-edit against the untouched on-disk source" contract the
|
|
@@ -1469,6 +2954,47 @@
|
|
|
1469
2954
|
// from the delegated `focusin` listener below. captureFn snapshots the
|
|
1470
2955
|
// surface's innerHTML; history.start() records snapshot 0 (the pre-edit
|
|
1471
2956
|
// baseline Esc reverts to).
|
|
2957
|
+
// S2 (Important): the "did the user actually change anything?" baseline —
|
|
2958
|
+
// `editEl.innerHTML` with SELECTION CHROME stripped. Every burst-level
|
|
2959
|
+
// comparison against `burst.original` (resolveBurst()'s zero-edit guard,
|
|
2960
|
+
// burstUndo()/tableBurstUndo()'s pristine-insert probes) and every
|
|
2961
|
+
// burst-history snapshot goes through here, so both sides of every such
|
|
2962
|
+
// comparison are normalised the same way.
|
|
2963
|
+
//
|
|
2964
|
+
// Why it has to exist: showRowMenu()/showColumnMenu() add '.ed-te-hl' to
|
|
2965
|
+
// LIVE cells before any burst exists, and the delete handler then opens
|
|
2966
|
+
// the burst — so a raw `tableEl.innerHTML` baseline BAKES THE HIGHLIGHT
|
|
2967
|
+
// IN. A refused delete ("無法刪除最後一列/欄") leaves that highlight
|
|
2968
|
+
// standing; the next click elsewhere strips it, and the baseline no longer
|
|
2969
|
+
// matches an untouched table. resolveBurst() then re-serialises the whole
|
|
2970
|
+
// thing through table-md.js's canonical form, silently destroying hand
|
|
2971
|
+
// padding and hand-written alignment in a table the user never edited.
|
|
2972
|
+
// Fixing only the refusal path (hideTableEdgeMenu() there) does not help:
|
|
2973
|
+
// stripping the class is itself the diff, whichever code path does it.
|
|
2974
|
+
// The same reasoning covers '.ed-te-row-dragging', which a drag adds and
|
|
2975
|
+
// pointerup/cancelTeDrag() removes.
|
|
2976
|
+
//
|
|
2977
|
+
// Fast path first: the overwhelming majority of calls (every paragraph /
|
|
2978
|
+
// heading / list burst, and any table with no selection on it) carry no
|
|
2979
|
+
// chrome at all, and must not pay for a full subtree clone. When chrome IS
|
|
2980
|
+
// present the clone is mutated instead of the live DOM, so the user's
|
|
2981
|
+
// visible selection survives the measurement. classList.remove() on the
|
|
2982
|
+
// clone re-serialises the class attribute joined by single spaces — byte-
|
|
2983
|
+
// identical to what the renderer emitted — and an attribute left empty is
|
|
2984
|
+
// dropped outright (the renderer never emits `class=""`).
|
|
2985
|
+
function burstBaselineHtml(editEl) {
|
|
2986
|
+
const html = editEl.innerHTML;
|
|
2987
|
+
if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1) return html;
|
|
2988
|
+
const clone = editEl.cloneNode(true);
|
|
2989
|
+
const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging');
|
|
2990
|
+
Array.prototype.forEach.call(marked, (el) => {
|
|
2991
|
+
el.classList.remove('ed-te-hl');
|
|
2992
|
+
el.classList.remove('ed-te-row-dragging');
|
|
2993
|
+
if (!el.className) el.removeAttribute('class');
|
|
2994
|
+
});
|
|
2995
|
+
return clone.innerHTML;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
1472
2998
|
function startBurst(editEl) {
|
|
1473
2999
|
const blockEl = editEl.closest('.ed-block');
|
|
1474
3000
|
if (!blockEl) return;
|
|
@@ -1476,12 +3002,12 @@
|
|
|
1476
3002
|
const block = blocks.find((b) => b.id === blockId);
|
|
1477
3003
|
if (!block) return;
|
|
1478
3004
|
const blockType = blockEl.getAttribute('data-block-type');
|
|
1479
|
-
const history = historyLib.createBurstHistory(() => editEl
|
|
3005
|
+
const history = historyLib.createBurstHistory(() => burstBaselineHtml(editEl), { debounceMs: 400 });
|
|
1480
3006
|
history.start();
|
|
1481
3007
|
currentBurst = {
|
|
1482
3008
|
blockEl, editEl, blockId, blockType,
|
|
1483
3009
|
depth: blockDepthOf(blockType, editEl),
|
|
1484
|
-
original: editEl
|
|
3010
|
+
original: burstBaselineHtml(editEl),
|
|
1485
3011
|
history,
|
|
1486
3012
|
};
|
|
1487
3013
|
selToolbarEditEl = editEl;
|
|
@@ -1545,15 +3071,19 @@
|
|
|
1545
3071
|
// otherwise-untouched source (hundreds of hand-formatted tables exist
|
|
1546
3072
|
// in real corpora) silently REWROTE it to the serializer's minimal form
|
|
1547
3073
|
// and marked the document dirty even though the user typed nothing.
|
|
1548
|
-
// `burst.original` is exactly `burst.editEl
|
|
1549
|
-
// focus time (startBurst()/startTableBurst() above, for
|
|
1550
|
-
// type this burst substrate covers — paragraph/heading/
|
|
1551
|
-
// store it the same way)
|
|
1552
|
-
//
|
|
3074
|
+
// `burst.original` is exactly `burstBaselineHtml(burst.editEl)`
|
|
3075
|
+
// captured at focus time (startBurst()/startTableBurst() above, for
|
|
3076
|
+
// every block type this burst substrate covers — paragraph/heading/
|
|
3077
|
+
// list/table all store it the same way): the surface's innerHTML with
|
|
3078
|
+
// table SELECTION CHROME normalised away, so a highlight that was
|
|
3079
|
+
// already standing when the burst opened (S2 — see burstBaselineHtml()
|
|
3080
|
+
// above) can neither be baked into the baseline nor show up as an edit
|
|
3081
|
+
// when a later click strips it. A byte-identical normalised innerHTML
|
|
3082
|
+
// means the DOM genuinely never changed, so drop the burst here like the
|
|
1553
3083
|
// `commitResult.op === null` no-op path below, without ever reaching
|
|
1554
3084
|
// the serializer (and therefore without ever risking a canonicalizing
|
|
1555
3085
|
// rewrite of untouched content).
|
|
1556
|
-
if (burst.editEl
|
|
3086
|
+
if (burstBaselineHtml(burst.editEl) === burst.original) {
|
|
1557
3087
|
endBurstWithoutResolve();
|
|
1558
3088
|
// §10-gap fix (review): untouched AND was pristine — an ordinary
|
|
1559
3089
|
// "insert +, click away without typing" changed-my-mind. Auto-remove
|
|
@@ -1563,17 +3093,49 @@
|
|
|
1563
3093
|
}
|
|
1564
3094
|
burst.history.flushTyping();
|
|
1565
3095
|
// Task 7 (Phase 4): li burst — serialize the whole list run through
|
|
1566
|
-
//
|
|
3096
|
+
// serializeBlocks(), commit via commitRangeEdit() over the full run range.
|
|
1567
3097
|
// Per-li degrade (spec §8): if OTHER lis in the run are unsupported,
|
|
1568
3098
|
// commit only the edited li's own line range to avoid lossy round-trip
|
|
1569
|
-
// of their content (
|
|
3099
|
+
// of their content (serializeBlocks strips unsupported inline elements from
|
|
1570
3100
|
// `md`, so whole-run commit would silently delete their content).
|
|
1571
3101
|
if (burst.blockType === 'li') {
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
|
|
3102
|
+
const editedLiEl = closestLiBlock(burst.editEl);
|
|
3103
|
+
const runEls = listRunOf(editedLiEl);
|
|
3104
|
+
if (!runEls.length) { endBurstWithoutResolve(); return true; }
|
|
3105
|
+
// T7 fix round 1 (HIGH-1): the §3.4 bystander replay belongs here too,
|
|
3106
|
+
// and this was the ONE list commit path that never got it — which made
|
|
3107
|
+
// "a line the user did not touch is never rewritten" false for the
|
|
3108
|
+
// commonest gesture of all, TYPING. The fully-supported branch below
|
|
3109
|
+
// commits `runMd` over the WHOLE run range, so every other item in the
|
|
3110
|
+
// run was re-serialized from the DOM on every keystroke burst:
|
|
3111
|
+
//
|
|
3112
|
+
// before: '- alpha one··\n alpha two ~t\n- bravo one··\n bravo two ~u\n- charlie ~v\n'
|
|
3113
|
+
// type one char into bravo, save
|
|
3114
|
+
// after: '- alpha one<br>alpha two \~t\n- bravo one<br>bravo two \~uZ\n- charlie \~v\n'
|
|
3115
|
+
//
|
|
3116
|
+
// — alpha's two source lines collapsed onto one bearing the literal
|
|
3117
|
+
// text '<br>' (the file lost a line) and charlie's '~v' was escaped,
|
|
3118
|
+
// in two items the user never opened.
|
|
3119
|
+
//
|
|
3120
|
+
// `mutatedEl` is the edited li: its DOM holds keystrokes `lines` has
|
|
3121
|
+
// not seen. bystanderCarryOver()'s dirty-burst exclusion already covers
|
|
3122
|
+
// it here (this is past the zero-edit guard, and `burst.blockId` IS this
|
|
3123
|
+
// li's id), so naming it is belt-and-braces — but it is the same fact
|
|
3124
|
+
// stated in the same place as the five structural call sites, which is
|
|
3125
|
+
// what stops the next reader having to re-derive it.
|
|
3126
|
+
//
|
|
3127
|
+
// The PARTIAL-run branch below is unaffected in OUTCOME, though not
|
|
3128
|
+
// untouched: a replayed bystander can emit a different number of lines
|
|
3129
|
+
// than a re-serialized one, so `first`/`last` shift. They index into
|
|
3130
|
+
// `runLines`, which is `runMd` from this very call, so the slice stays
|
|
3131
|
+
// self-consistent — and the bystanders' lines never reach `lines` on
|
|
3132
|
+
// that path anyway, because it commits only the edited block's own
|
|
3133
|
+
// source range.
|
|
3134
|
+
const carry = bystanderCarryOver(runEls, editedLiEl);
|
|
3135
|
+
const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
|
|
3136
|
+
listMd.serializeBlocks(runEls, { carryOver: carry });
|
|
1575
3137
|
// Refuse if the EDITED li itself has unsupported inline content.
|
|
1576
|
-
// RULING F-O: do NOT call openRawEditor() on a
|
|
3138
|
+
// RULING F-O: do NOT call openRawEditor() on a list block — injecting
|
|
1577
3139
|
// a textarea into list structure corrupts list-md serialization and
|
|
1578
3140
|
// renders badly. Show banner + teardown + rerenderAll (file is untouched,
|
|
1579
3141
|
// burst never wrote to `lines`) + return false.
|
|
@@ -1584,7 +3146,7 @@
|
|
|
1584
3146
|
await safeRerenderAll();
|
|
1585
3147
|
return false;
|
|
1586
3148
|
}
|
|
1587
|
-
const range =
|
|
3149
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
|
|
1588
3150
|
if (!range) { endBurstWithoutResolve(); return true; }
|
|
1589
3151
|
let commitMd, commitStart, commitEnd;
|
|
1590
3152
|
if (unsupported.length > 0) {
|
|
@@ -1603,25 +3165,42 @@
|
|
|
1603
3165
|
// loose-'P', stray-TEXT and foreign-element cases in one shot.
|
|
1604
3166
|
const editedBlock = blocks.find((b) => b.id === burst.blockId);
|
|
1605
3167
|
if (!editedBlock) { endBurstWithoutResolve(); return true; }
|
|
1606
|
-
// F-W (the trap): the slice
|
|
1607
|
-
//
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
3168
|
+
// F-W (the trap): the slice MUST be located among the run's emitted
|
|
3169
|
+
// LINES, NOT by the source-line delta
|
|
3170
|
+
// (editedBlock.startLine - range.startLine). runMd has no blank lines,
|
|
3171
|
+
// so a loose item present anywhere earlier in the run makes a later
|
|
3172
|
+
// supported li's SOURCE startLine overshoot runMd's line count — the
|
|
3173
|
+
// old delta slice then returned '' and commitRangeRemoval DELETED the
|
|
3174
|
+
// li's line.
|
|
3175
|
+
//
|
|
3176
|
+
// `lineMeta` is the serializer's own authoritative line -> blockId
|
|
3177
|
+
// mapping and is read instead of re-deriving anything from the DOM.
|
|
3178
|
+
// Two reasons position arithmetic cannot be used: a block the
|
|
3179
|
+
// serializer refuses emits NO line (controller note T2-C), and a
|
|
3180
|
+
// hard-wrapped block emits SEVERAL — so a block maps to the index
|
|
3181
|
+
// RANGE of the entries bearing its id, and the commit replaces that
|
|
3182
|
+
// whole range. Taking only the first line here is what overwrote a
|
|
3183
|
+
// later item's source with a continuation line. lineMeta's blockId is
|
|
3184
|
+
// the raw getAttribute() string, hence the String() comparison (same
|
|
3185
|
+
// convention as the unsupportedByLi gate above).
|
|
3186
|
+
const runLines = runMd.split('\n');
|
|
3187
|
+
let first = -1;
|
|
3188
|
+
let last = -1;
|
|
3189
|
+
lineMeta.forEach((m, k) => {
|
|
3190
|
+
if (m.blockId !== editedIdStr) return;
|
|
3191
|
+
if (first < 0) first = k;
|
|
3192
|
+
last = k;
|
|
3193
|
+
});
|
|
3194
|
+
if (first < 0) { endBurstWithoutResolve(); return true; }
|
|
3195
|
+
// Round 5/6: the edited block's source line may also carry the markers
|
|
3196
|
+
// of zero-line ancestors (same-line nesting, '- - b'). They emit their
|
|
3197
|
+
// own lines in runMd but are not attributed to this block, so slicing
|
|
3198
|
+
// by id alone dropped them and the child lost its parent. A plain
|
|
3199
|
+
// ancestor is re-emitted on a line of its own; a TASK ancestor has to
|
|
3200
|
+
// stay on the child's line or its checkbox degrades to literal text.
|
|
3201
|
+
// See sharedMarkerPrefixFor().
|
|
3202
|
+
commitMd = sharedMarkerLinesBefore(lineMeta, first, editedBlock)
|
|
3203
|
+
.concat(runLines.slice(first, last + 1)).join('\n');
|
|
1625
3204
|
commitStart = editedBlock.startLine;
|
|
1626
3205
|
commitEnd = editedBlock.endLine;
|
|
1627
3206
|
} else {
|
|
@@ -1641,26 +3220,19 @@
|
|
|
1641
3220
|
lines = liCommitResult.lines;
|
|
1642
3221
|
const liOk = await safeRerenderAll();
|
|
1643
3222
|
if (!liOk) {
|
|
1644
|
-
|
|
1645
|
-
lines = liRollback ? liRollback.lines : liPrevLines;
|
|
3223
|
+
lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
|
|
1646
3224
|
return false;
|
|
1647
3225
|
}
|
|
1648
3226
|
return true;
|
|
1649
3227
|
}
|
|
1650
|
-
// Task
|
|
1651
|
-
//
|
|
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
|
|
3228
|
+
// Task 5: a table burst serializes through table-md.js's serializeTable()
|
|
3229
|
+
// (it takes the TABLE element, exactly what burst.editEl already is for a
|
|
1656
3230
|
// 'table' burst). Every other block type (paragraph/heading) keeps using
|
|
1657
|
-
// serializeInline() unchanged.
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
|
|
1662
|
-
const result = burst.blockType === 'list' ? listMd.serializeList(burst.editEl)
|
|
1663
|
-
: burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
|
|
3231
|
+
// inline-md.js's serializeInline() unchanged.
|
|
3232
|
+
// S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
|
|
3233
|
+
// emitted type:'list' blocks since Phase 4, so no startBurst() call could
|
|
3234
|
+
// produce blockType === 'list' and the branch was already dead code.
|
|
3235
|
+
const result = burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
|
|
1664
3236
|
: inlineMd.serializeInline(burst.editEl);
|
|
1665
3237
|
if (result.unsupported.length > 0) {
|
|
1666
3238
|
// Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
|
|
@@ -1677,23 +3249,16 @@
|
|
|
1677
3249
|
return false;
|
|
1678
3250
|
}
|
|
1679
3251
|
// Final-review Finding 5 (carried over): an emptied-out heading must not
|
|
1680
|
-
// commit '#'.repeat(depth) + ' ' with nothing after the space.
|
|
1681
|
-
// burst's `depth` is
|
|
1682
|
-
// 'heading'), so it takes the plain result.md branch
|
|
1683
|
-
// paragraph.
|
|
3252
|
+
// commit '#'.repeat(depth) + ' ' with nothing after the space. Every
|
|
3253
|
+
// non-heading burst's `depth` is null (blockDepthOf() only computes it for
|
|
3254
|
+
// 'heading'), so it takes the plain result.md branch.
|
|
1684
3255
|
const newText = burst.depth === null ? result.md :
|
|
1685
3256
|
(result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
1690
|
-
|
|
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);
|
|
3257
|
+
// S1 removed the pre-per-li "a whole-list burst that serialized to ''"
|
|
3258
|
+
// branch that lived here alongside the dead 'list' serializer arm above.
|
|
3259
|
+
// The per-li equivalent — a run whose every item was removed — is handled
|
|
3260
|
+
// by commitListStructure()'s own md === '' path.
|
|
3261
|
+
const commitResult = commitEdit({ lines, blocks, stack }, burst.blockId, newText);
|
|
1697
3262
|
if (commitResult.op === null) {
|
|
1698
3263
|
endBurstWithoutResolve();
|
|
1699
3264
|
return true;
|
|
@@ -1702,8 +3267,7 @@
|
|
|
1702
3267
|
lines = commitResult.lines;
|
|
1703
3268
|
const ok = await safeRerenderAll();
|
|
1704
3269
|
if (!ok) {
|
|
1705
|
-
|
|
1706
|
-
lines = rollback ? rollback.lines : prevLines;
|
|
3270
|
+
lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
|
|
1707
3271
|
// Burst stays open: DOM/history untouched, banner already shown by
|
|
1708
3272
|
// safeRerenderAll(). rerenderAll() never ran its belt-and-braces
|
|
1709
3273
|
// `currentBurst = null` reset on this failure path (that reset only
|
|
@@ -1732,6 +3296,49 @@
|
|
|
1732
3296
|
return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
1733
3297
|
}
|
|
1734
3298
|
|
|
3299
|
+
// ── T7: surviving a commit that renumbers every block id ───────────────
|
|
3300
|
+
// A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
|
|
3301
|
+
// that resolution can commit a DIFFERENT block's dirty editor, re-render,
|
|
3302
|
+
// and detach the element the gesture started from. Recovering by
|
|
3303
|
+
// `data-block-id` is not recovery at all: blockmap.js assigns ids 0..n-1 in
|
|
3304
|
+
// document order on EVERY render (`nextId = {v:0}`), so a commit that
|
|
3305
|
+
// changes the block COUNT shifts every later id and the captured id then
|
|
3306
|
+
// names the target's NEIGHBOUR. Measured: a fenced code block raw-edited
|
|
3307
|
+
// into two paragraphs changes the count WITHOUT changing the line count, so
|
|
3308
|
+
// '⠿ → 刪除' on the last paragraph deleted the one before it instead.
|
|
3309
|
+
//
|
|
3310
|
+
// Same defect class the S1 table fix closed (ensureTableBurstOpen()'s own
|
|
3311
|
+
// comment), and the same remedy: `startLine` is the stable handle, and the
|
|
3312
|
+
// block's own SOURCE LINES are the fingerprint proving the block sitting
|
|
3313
|
+
// there afterwards really is the same one. When the intervening commit moved
|
|
3314
|
+
// the target's own start line there is nothing left to resolve — the
|
|
3315
|
+
// fingerprint fails, the caller DROPS the gesture and says so. Never
|
|
3316
|
+
// guessed: completing every gesture is worth less than never acting on a
|
|
3317
|
+
// block the user did not point at.
|
|
3318
|
+
function blockSourceOf(block) {
|
|
3319
|
+
return lines.slice(block.startLine - 1, block.endLine).join('\n');
|
|
3320
|
+
}
|
|
3321
|
+
function captureBlockIdentity(blockEl) {
|
|
3322
|
+
if (!blockEl) return null;
|
|
3323
|
+
const raw = blockEl.getAttribute('data-block-id');
|
|
3324
|
+
if (raw === null) return null;
|
|
3325
|
+
const b = blocks.find((x) => x.id === Number(raw));
|
|
3326
|
+
if (!b) return null;
|
|
3327
|
+
return { startLine: b.startLine, type: b.type, source: blockSourceOf(b) };
|
|
3328
|
+
}
|
|
3329
|
+
function reresolveBlockEl(identity) {
|
|
3330
|
+
if (!identity) return null;
|
|
3331
|
+
const at = blocks.find((b) => b.startLine === identity.startLine);
|
|
3332
|
+
if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
|
|
3333
|
+
return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// What a caller says when it refuses to act rather than act on the wrong
|
|
3337
|
+
// block. Dismiss-only, same shape as refuseStructuralListEdit()'s banner —
|
|
3338
|
+
// the previous behaviour was to return silently, which reads to the user as
|
|
3339
|
+
// "the menu item is broken" and invites a second press.
|
|
3340
|
+
const DROPPED_GESTURE_MESSAGE = '文件已更新,請重試這個操作';
|
|
3341
|
+
|
|
1735
3342
|
// Finds the block whose startLine === `startLine` in the current `blocks`
|
|
1736
3343
|
// array and focuses its WYSIWYG surface. `caretToEnd` = true places the
|
|
1737
3344
|
// caret after the last character; false (default) places it at the start.
|
|
@@ -1759,7 +3366,7 @@
|
|
|
1759
3366
|
}
|
|
1760
3367
|
|
|
1761
3368
|
// ── Task 8 (Phase 4): structural commit for a per-li block run ──────────
|
|
1762
|
-
// Spec §3: a single
|
|
3369
|
+
// Spec §3: a single list item cannot emit its own line (ordinals and ancestor
|
|
1763
3370
|
// marker widths are tree-global), so the commit unit for ANY structural
|
|
1764
3371
|
// change is the contiguous list RUN — re-serialize the whole run, replace
|
|
1765
3372
|
// its line range once. That keeps every structural key at exactly ONE undo
|
|
@@ -1769,18 +3376,20 @@
|
|
|
1769
3376
|
// handler — Task 9's delegated checkbox-toggle click handler calls this too.
|
|
1770
3377
|
//
|
|
1771
3378
|
// The DOM mutation must already have happened when this is called; it reads
|
|
1772
|
-
// the live run back out through listMd.
|
|
3379
|
+
// the live run back out through listMd.serializeBlocks(). `focusStartLine` is
|
|
1773
3380
|
// the (post-commit) line the caret should end up on — see
|
|
1774
|
-
//
|
|
3381
|
+
// runLineOfBlock() below for how a caller computes it — or null to leave
|
|
1775
3382
|
// focus wherever the re-render puts it. Returns true on success, false when
|
|
1776
3383
|
// the commit's own re-render failed (rolled back the same way every other
|
|
1777
3384
|
// commit path in this file does: stack.undo() + restore `lines`).
|
|
1778
3385
|
//
|
|
1779
|
-
// `
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
3386
|
+
// S1: `runEls` is the POST-mutation run span itself (listRunOf() on any block
|
|
3387
|
+
// still in the run), not a node to walk up from — the flat model has no list
|
|
3388
|
+
// container left to resolve. A caller whose mutation REMOVED the block the
|
|
3389
|
+
// key came from therefore computes the span from a surviving sibling, or
|
|
3390
|
+
// passes an empty array plus a `presetRange` when the run has no members
|
|
3391
|
+
// left; an empty span serializes to '' and takes the range-removal path
|
|
3392
|
+
// below, exactly as an emptied run did before.
|
|
1784
3393
|
// Both "cannot locate the run" refusals below re-render before returning: the
|
|
1785
3394
|
// caller's DOM mutation has ALREADY happened by the time this function runs,
|
|
1786
3395
|
// so bailing out without a render would leave the screen showing a structural
|
|
@@ -1793,20 +3402,100 @@
|
|
|
1793
3402
|
// re-serializes the WHOLE run — an unsupported li anywhere in it would have
|
|
1794
3403
|
// its content silently deleted if the gate is skipped. The keydown handlers
|
|
1795
3404
|
// (Tab, Enter) and the Task 9 checkbox click handler both enforce this.
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
3405
|
+
// Spec §3.4's bystander rule, resolved against the live file state: every
|
|
3406
|
+
// block in the commit span that the gesture did not itself touch, mapped to
|
|
3407
|
+
// the source lines it owns right now, so listMd.serializeBlocks() can replay
|
|
3408
|
+
// them instead of running them back through the (measurably lossy) inline
|
|
3409
|
+
// round trip. See its own carryOver comment for the measurement.
|
|
3410
|
+
//
|
|
3411
|
+
// ── T7: EVERY untouched block, keyed on its LINE RANGE ─────────────────
|
|
3412
|
+
// This used to name only the blocks the serializer reported in
|
|
3413
|
+
// `multiLineBlockIds`, and both halves of that were wrong.
|
|
3414
|
+
//
|
|
3415
|
+
// * WRONG SET. `multiLineBlockIds` answers "does the surface text hold a
|
|
3416
|
+
// '\n'", which is blind to a markdown HARD BREAK (two trailing spaces →
|
|
3417
|
+
// <br>, no newline in the DOM). Such an item was never carried, so a Tab
|
|
3418
|
+
// on a SIBLING re-serialised it: its two source lines collapsed onto one
|
|
3419
|
+
// line bearing the literal text '<br>', and the file lost a line in an
|
|
3420
|
+
// item nobody touched. list-md.js's detector has since been widened, but
|
|
3421
|
+
// the truth about how many lines a block owns lives HERE — in `blocks` —
|
|
3422
|
+
// not in a DOM heuristic, so that is what this reads.
|
|
3423
|
+
// * WRONG QUESTION. Even a genuinely single-line bystander must not be
|
|
3424
|
+
// re-serialised: escapeText() escapes a tilde marked never treats as
|
|
3425
|
+
// markup, so an untouched '~5px' came back '\~5px'. Carrying EVERY
|
|
3426
|
+
// untouched block makes "a line the user did not touch is never
|
|
3427
|
+
// rewritten" a property of the commit, rather than a special case for
|
|
3428
|
+
// hard-wrapped items. (Chosen over teaching inline-md.js not to escape a
|
|
3429
|
+
// lone '~': that changes a global serialisation rule and every other
|
|
3430
|
+
// caller with it, and it would still leave the next such character to
|
|
3431
|
+
// find. Controller note T7-B picked the same half.)
|
|
3432
|
+
//
|
|
3433
|
+
// The probe serialisation this used to run for the id list is gone with it,
|
|
3434
|
+
// which also takes one of the four serializeBlocks() passes a single Tab
|
|
3435
|
+
// used to make off the hot path.
|
|
3436
|
+
//
|
|
3437
|
+
// Three exclusions, all about "whose bytes are authoritative":
|
|
3438
|
+
//
|
|
3439
|
+
// * `mutatedEl` — the block the gesture REWROTE in the DOM before calling
|
|
3440
|
+
// the commit (Enter's split cuts its text in two; the empty-item outdent
|
|
3441
|
+
// clears its surface). Replaying its source would undo exactly that. A
|
|
3442
|
+
// column-only caller (Tab, the checkbox toggle) names nothing here, on
|
|
3443
|
+
// purpose: it changed an integer, not content, so its own target is a
|
|
3444
|
+
// bystander of itself and must come back byte-identical too.
|
|
3445
|
+
// * the block of an open burst whose surface has ACTUALLY been edited. Its
|
|
3446
|
+
// DOM holds keystrokes `lines` has not seen, and replaying `lines` would
|
|
3447
|
+
// silently throw them away. The dirty test is resolveBurst()'s own
|
|
3448
|
+
// zero-edit guard, so both places agree on what "edited" means — and an
|
|
3449
|
+
// UNEDITED burst is deliberately still replayed, because that is the
|
|
3450
|
+
// common case for Tab (click into an item, press Tab) and re-serializing
|
|
3451
|
+
// it would rewrite bytes the user only pressed an indent key on.
|
|
3452
|
+
// * a block with no resolvable, non-inverted range — a provisional split
|
|
3453
|
+
// item owns no source lines at all (no id yet), and a same-line nest's
|
|
3454
|
+
// outer item has endLine === startLine - 1. Neither has bytes to replay,
|
|
3455
|
+
// and slicing an inverted range would hand back the WRONG line. The
|
|
3456
|
+
// predicate is blockOwnsNoLine() itself rather than a second hand-typed
|
|
3457
|
+
// copy of `endLine < startLine`, so a change to that definition reaches
|
|
3458
|
+
// here too.
|
|
3459
|
+
function bystanderCarryOver(span, mutatedEl) {
|
|
3460
|
+
const dirtyId = (currentBurst && currentBurst.editEl &&
|
|
3461
|
+
burstBaselineHtml(currentBurst.editEl) !== currentBurst.original)
|
|
3462
|
+
? String(currentBurst.blockId) : null;
|
|
3463
|
+
const out = {};
|
|
3464
|
+
let any = false;
|
|
3465
|
+
(span || []).forEach((el) => {
|
|
3466
|
+
if (!el || el === mutatedEl) return;
|
|
3467
|
+
const raw = el.getAttribute('data-block-id');
|
|
3468
|
+
if (raw === null || raw === dirtyId) return;
|
|
3469
|
+
const rec = blocks.find((b) => b.id === Number(raw));
|
|
3470
|
+
if (!rec || blockOwnsNoLine(el)) return;
|
|
3471
|
+
out[raw] = lines.slice(rec.startLine - 1, rec.endLine);
|
|
3472
|
+
any = true;
|
|
3473
|
+
});
|
|
3474
|
+
return any ? out : null;
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// `opts`: { presetRange, carryOver }. `carryOver` is the map
|
|
3478
|
+
// bystanderCarryOver() built — the CALLER builds it, once, right after its
|
|
3479
|
+
// own DOM mutation, and hands the SAME object to runLineOfBlock() as well:
|
|
3480
|
+
// the two must agree line-for-line (a replayed bystander can emit a
|
|
3481
|
+
// different number of lines than a re-serialized one would), and building it
|
|
3482
|
+
// twice also meant walking `blocks` twice per keystroke. Omitted (or null)
|
|
3483
|
+
// means "no bystander replay", which is only correct for a span whose blocks
|
|
3484
|
+
// all have their bytes in the DOM.
|
|
3485
|
+
async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
|
|
3486
|
+
const span = runEls || [];
|
|
3487
|
+
const presetRange = opts && opts.presetRange;
|
|
3488
|
+
const { md } = listMd.serializeBlocks(span, { carryOver: (opts && opts.carryOver) || null });
|
|
1800
3489
|
// The run's line range is read back off its own li blocks' ids — which
|
|
1801
3490
|
// requires at least one li to still BE there. A caller whose mutation
|
|
1802
3491
|
// removed the run's last item therefore captures the range BEFORE mutating
|
|
1803
3492
|
// and passes it in; everyone else lets it be derived here.
|
|
1804
|
-
const range = presetRange ||
|
|
3493
|
+
const range = presetRange || runRangeOfBlocks({ lines, blocks, stack }, span);
|
|
1805
3494
|
if (!range) { endBurstWithoutResolve(); await safeRerenderAll(); return false; }
|
|
1806
3495
|
const result = (md === '')
|
|
1807
|
-
// Every
|
|
1808
|
-
// the run has no items left — delete the range outright (absorbing
|
|
1809
|
-
// adjacent blank separator) instead of committing a stray blank line.
|
|
3496
|
+
// Every list block emits a non-empty marker line, so md === '' can only
|
|
3497
|
+
// mean the run has no items left — delete the range outright (absorbing
|
|
3498
|
+
// one adjacent blank separator) instead of committing a stray blank line.
|
|
1810
3499
|
// Same contract commitListBlockRemoval() documents.
|
|
1811
3500
|
? commitRangeRemoval({ lines, blocks, stack }, range.startLine, range.endLine)
|
|
1812
3501
|
: commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
|
|
@@ -1822,10 +3511,7 @@
|
|
|
1822
3511
|
if (result.op !== null) lines = result.lines;
|
|
1823
3512
|
const ok = await safeRerenderAll();
|
|
1824
3513
|
if (!ok) {
|
|
1825
|
-
|
|
1826
|
-
const rollback = stack.undo(lines);
|
|
1827
|
-
lines = rollback ? rollback.lines : prevLines;
|
|
1828
|
-
}
|
|
3514
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1829
3515
|
// Deliberately NOT a second safeRerenderAll(), unlike the two refusals
|
|
1830
3516
|
// above: this shape is different — a render WAS attempted and failed, so
|
|
1831
3517
|
// rerenderAll() left `.content` untouched by contract and already showed
|
|
@@ -1841,30 +3527,47 @@
|
|
|
1841
3527
|
return true;
|
|
1842
3528
|
}
|
|
1843
3529
|
|
|
1844
|
-
// The line `
|
|
1845
|
-
// commitListStructure() above.
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1853
|
-
// Returns null when the run (or the
|
|
1854
|
-
function
|
|
1855
|
-
const range =
|
|
1856
|
-
if (!range) return null;
|
|
1857
|
-
const
|
|
1858
|
-
|
|
3530
|
+
// The line `targetBlock`'s own marker will occupy once the run span it belongs
|
|
3531
|
+
// to is committed by commitListStructure() above.
|
|
3532
|
+
//
|
|
3533
|
+
// Counted in EMITTED LINES, not in blocks: a block index is only the line
|
|
3534
|
+
// offset while every block emits exactly one line, and neither end of that
|
|
3535
|
+
// holds — a refused block emits none (controller note T2-C) and a
|
|
3536
|
+
// hard-wrapped one emits several. `lineMeta` is the serializer's own
|
|
3537
|
+
// line -> blockId mapping, so the answer is the index of the FIRST entry
|
|
3538
|
+
// bearing this block's id (its marker line; continuation entries follow).
|
|
3539
|
+
// Returns null when the run (or the block) cannot be located.
|
|
3540
|
+
function runLineOfBlock(runEls, targetBlock, carryOver) {
|
|
3541
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
|
|
3542
|
+
if (!range || !targetBlock) return null;
|
|
3543
|
+
const targetId = targetBlock.getAttribute('data-block-id');
|
|
3544
|
+
// The SAME carryOver commitListStructure() will use — passed in by the
|
|
3545
|
+
// caller rather than rebuilt here, because a rebuild is only *usually* the
|
|
3546
|
+
// same answer and this index has to be exactly it: a replayed bystander
|
|
3547
|
+
// can emit a different number of lines than a re-serialized one would (a
|
|
3548
|
+
// blank continuation is dropped on the re-serialize path but replayed
|
|
3549
|
+
// verbatim here), and this answer is an index INTO those lines.
|
|
3550
|
+
const { lineMeta } = listMd.serializeBlocks(runEls, { carryOver: carryOver || null });
|
|
3551
|
+
// A provisional block (split's new item) has no id yet, so it cannot be
|
|
3552
|
+
// found by one — fall back to counting the lines emitted before it.
|
|
3553
|
+
if (targetId === null) {
|
|
3554
|
+
const at = runEls.indexOf(targetBlock);
|
|
3555
|
+
if (at < 0) return null;
|
|
3556
|
+
const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
|
|
3557
|
+
let n = 0;
|
|
3558
|
+
lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
|
|
3559
|
+
return range.startLine + n;
|
|
3560
|
+
}
|
|
3561
|
+
const idx = lineMeta.findIndex((m) => m.blockId === targetId);
|
|
1859
3562
|
if (idx === -1) return null;
|
|
1860
3563
|
return range.startLine + idx;
|
|
1861
3564
|
}
|
|
1862
3565
|
|
|
1863
3566
|
// Degrade-never-lose gate for structural keys: refuse the key outright when
|
|
1864
|
-
// ANY
|
|
1865
|
-
// stray text directly
|
|
1866
|
-
//
|
|
1867
|
-
// such a run deletes that content silently.
|
|
3567
|
+
// ANY block in the run span is unsupported (loose <p>-wrapped item, foreign
|
|
3568
|
+
// child element, stray text directly inside the block, unsupported inline
|
|
3569
|
+
// markup). serializeBlocks() strips what it cannot represent from `md`, so
|
|
3570
|
+
// committing such a run deletes that content silently.
|
|
1868
3571
|
//
|
|
1869
3572
|
// RULING F-R — why the gate is RUN-WIDE, and why that does not contradict
|
|
1870
3573
|
// spec §8's per-li narrowing. §8 governs which li you may TYPE in: a text
|
|
@@ -1879,8 +3582,93 @@
|
|
|
1879
3582
|
// answer, not an over-broad one.
|
|
1880
3583
|
//
|
|
1881
3584
|
// Called BEFORE any mutation, so a refusal costs nothing to undo.
|
|
1882
|
-
|
|
1883
|
-
|
|
3585
|
+
//
|
|
3586
|
+
// ── Task 6: MULTILINE is a TARGET-ONLY refusal ─────────────────────────
|
|
3587
|
+
// Everything above stays true for content the serializer cannot represent.
|
|
3588
|
+
// A hard-wrapped item is a different animal: it is ordinary, valid markdown
|
|
3589
|
+
// that the serializer represents perfectly well — it just owns several lines
|
|
3590
|
+
// instead of one. Feeding it into the run-wide rule was measured, on this
|
|
3591
|
+
// repo's own CHANGELOG.md, to refuse Tab on 100% of list items (80.6% of that
|
|
3592
|
+
// file's 72 items are hard-wrapped, so effectively every run holds one). S1's
|
|
3593
|
+
// headline gesture was therefore dead on any real document.
|
|
3594
|
+
//
|
|
3595
|
+
// Spec §4.1 splits the roles instead: hard-wrapped refuses as the operation
|
|
3596
|
+
// TARGET (its own line range is what a split / convert / delete would have to
|
|
3597
|
+
// rewrite, and no caller here knows how), and as a BYSTANDER it is carried
|
|
3598
|
+
// through untouched — see commitListStructure()'s carryOver below, which
|
|
3599
|
+
// replays its source bytes rather than re-serializing it.
|
|
3600
|
+
//
|
|
3601
|
+
// `targetEl` is the block the gesture acts on. It is NOT optional in spirit:
|
|
3602
|
+
// omitting it falls back to the old run-wide answer, which is the safe
|
|
3603
|
+
// direction but also the useless one, so every call site names its target.
|
|
3604
|
+
//
|
|
3605
|
+
// ── DEVIATION from spec §4.1, with the measurement that forced it ──────
|
|
3606
|
+
// §4.1 lists Tab among the operations a hard-wrapped item refuses AS THE
|
|
3607
|
+
// TARGET. Implemented literally, that does not deliver the acceptance
|
|
3608
|
+
// condition this task was given ("Tab must work on CHANGELOG.md"), and the
|
|
3609
|
+
// reason is arithmetic rather than opinion: measured on this repo's
|
|
3610
|
+
// CHANGELOG.md at v2.10.2, 58 of 72 list items are hard-wrapped and NOT ONE
|
|
3611
|
+
// of the 14 single-line items shares a run with one. Target-only refusal
|
|
3612
|
+
// therefore moves the number of items that accept Tab from 14/72 to 14/72 —
|
|
3613
|
+
// it changes nothing at all on that document, because there the
|
|
3614
|
+
// hard-wrapped item is never the bystander, it is the item you want to
|
|
3615
|
+
// indent.
|
|
3616
|
+
//
|
|
3617
|
+
// What the rest of §4.1's list has in common is that it REWRITES the item's
|
|
3618
|
+
// content or its line count: a split cuts the text in two, a conversion
|
|
3619
|
+
// re-authors it as a fence or a paragraph, a delete removes its lines, a
|
|
3620
|
+
// duplicate re-emits them. None of those has a defined answer for an item
|
|
3621
|
+
// whose content spans several source lines, which is what the refusal is
|
|
3622
|
+
// protecting.
|
|
3623
|
+
//
|
|
3624
|
+
// Tab and Shift+Tab are not in that family. They change one integer and
|
|
3625
|
+
// nothing else, and the resulting byte change is EXACTLY §3.4's colDelta —
|
|
3626
|
+
// "對其 [startLine, endLine] 每一行套用同一欄位差", the same mechanism the
|
|
3627
|
+
// spec already defines for a bystander, pointed at the target instead. So
|
|
3628
|
+
// `opts.columnOnly` lets the indent keys through, and every other structural
|
|
3629
|
+
// caller keeps §4.1's refusal untouched.
|
|
3630
|
+
//
|
|
3631
|
+
// `columnOnly` is a CRITERION, not the name of two keys (T7, and §4.1 has
|
|
3632
|
+
// been amended to match so the next such operation needs no fresh ruling):
|
|
3633
|
+
// an operation is column-only when it changes no content, no line count, and
|
|
3634
|
+
// nothing but leading columns or the characters inside a marker. The GFM
|
|
3635
|
+
// checkbox toggle qualifies on exactly the same arithmetic as Tab — '[ ] '
|
|
3636
|
+
// and '[x] ' are the same width, so its colDelta is 0 — and it was the
|
|
3637
|
+
// second caller to need it. Anything that rewrites the item's TEXT or its
|
|
3638
|
+
// LINE COUNT (split, convert, delete, duplicate) is not column-only and must
|
|
3639
|
+
// keep refusing a multi-line target.
|
|
3640
|
+
//
|
|
3641
|
+
// The one thing that must not happen is replaying stale bytes over live
|
|
3642
|
+
// keystrokes; bystanderCarryOver() below is what draws that line, by
|
|
3643
|
+
// excluding a burst whose surface has actually been edited.
|
|
3644
|
+
function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
|
|
3645
|
+
if (!runEls || !runEls.length) return false;
|
|
3646
|
+
const res = listMd.serializeBlocks(runEls);
|
|
3647
|
+
const multi = res.multiLineBlockIds || [];
|
|
3648
|
+
// Anything OTHER than MULTILINE still refuses run-wide, unchanged.
|
|
3649
|
+
for (let i = 0; i < res.unsupported.length; i++) {
|
|
3650
|
+
if (res.unsupported[i] !== 'MULTILINE') return false;
|
|
3651
|
+
}
|
|
3652
|
+
if (opts && opts.columnOnly) return true;
|
|
3653
|
+
if (!targetEl) return multi.length === 0;
|
|
3654
|
+
// T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
|
|
3655
|
+
// `multiLineBlockIds` reports a '\n' in the item's surface text, which
|
|
3656
|
+
// sees a LAZY continuation and is blind to a markdown HARD BREAK (two
|
|
3657
|
+
// trailing spaces -> <br>, no newline in the DOM). Enter on such an item
|
|
3658
|
+
// was therefore accepted, and re-serialised its two source lines into one
|
|
3659
|
+
// line bearing the literal text '<br>' — precisely the rewrite §4.1's
|
|
3660
|
+
// refusal exists to prevent. How many lines a block owns is a fact about
|
|
3661
|
+
// the FILE, so it is read off `blocks` here rather than guessed from the
|
|
3662
|
+
// DOM in list-md.js (which was tried: '<br>' also matches the placeholder
|
|
3663
|
+
// Chromium leaves when the last character is deleted, and an emptied item
|
|
3664
|
+
// must stay removable). `multi` is kept as well — it costs nothing and
|
|
3665
|
+
// covers any surface newline that is not a line-range fact.
|
|
3666
|
+
const targetRaw = targetEl.getAttribute('data-block-id');
|
|
3667
|
+
const targetRec = blocks.find((b) => b.id === Number(targetRaw));
|
|
3668
|
+
if (targetRec && targetRec.endLine > targetRec.startLine) return false;
|
|
3669
|
+
// getAttribute() strings on both sides — the same convention
|
|
3670
|
+
// unsupportedByLi[].blockId uses.
|
|
3671
|
+
return multi.indexOf(targetRaw) === -1;
|
|
1884
3672
|
}
|
|
1885
3673
|
|
|
1886
3674
|
// Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
|
|
@@ -1934,7 +3722,7 @@
|
|
|
1934
3722
|
// to resolve — nothing can change either condition between this check
|
|
1935
3723
|
// and that resolution running.
|
|
1936
3724
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
1937
|
-
burst.editEl
|
|
3725
|
+
burstBaselineHtml(burst.editEl) === burst.original);
|
|
1938
3726
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
1939
3727
|
}
|
|
1940
3728
|
|
|
@@ -1994,13 +3782,31 @@
|
|
|
1994
3782
|
if (currentBurst.blockType === 'li') {
|
|
1995
3783
|
if (handleLiKeydown(e, editEl)) return;
|
|
1996
3784
|
}
|
|
1997
|
-
// Task
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
// (
|
|
2001
|
-
//
|
|
2002
|
-
|
|
2003
|
-
|
|
3785
|
+
// Task 6 — spec §3.5's other two rows. Tab is CONSUMED here: the
|
|
3786
|
+
// alternative is not "nothing happens", it is the browser's own focus
|
|
3787
|
+
// traversal walking the caret out of the document body, which is both a
|
|
3788
|
+
// surprise and (because it fires focusout) an unasked-for commit.
|
|
3789
|
+
//
|
|
3790
|
+
// heading — one level down / up, clamped to H1..H6 by
|
|
3791
|
+
// changeHeadingDepth(), which is the same source-level
|
|
3792
|
+
// transform the ⠿ menu's ± buttons already use.
|
|
3793
|
+
// paragraph — a true no-op. Not "unhandled": preventDefault() and
|
|
3794
|
+
// return, so the block is byte-identical afterwards.
|
|
3795
|
+
//
|
|
3796
|
+
// T7 correction: that is the WHOLE list, not a sample of it. This branch
|
|
3797
|
+
// runs only for a block with an open burst, and armEditables() opens one
|
|
3798
|
+
// for exactly four block types — paragraph, heading, li, table. `li` has
|
|
3799
|
+
// already returned above (handleLiKeydown()), and a table cell never
|
|
3800
|
+
// reaches here at all (it runs through handleTableCellKeydown(), whose Tab
|
|
3801
|
+
// keeps its cell-navigation contract). Blockquote and fenced code are
|
|
3802
|
+
// never armed — they are degraded blocks whose click opens the raw
|
|
3803
|
+
// textarea — so no "consumed no-op" branch has ever executed for them,
|
|
3804
|
+
// whatever the commit message that introduced this said.
|
|
3805
|
+
if (e.key === 'Tab') {
|
|
3806
|
+
e.preventDefault();
|
|
3807
|
+
if (currentBurst.blockType === 'heading') {
|
|
3808
|
+
changeHeadingDepth(currentBurst.blockEl, e.shiftKey ? -1 : 1);
|
|
3809
|
+
}
|
|
2004
3810
|
return;
|
|
2005
3811
|
}
|
|
2006
3812
|
if (e.key === 'Enter') {
|
|
@@ -2041,136 +3847,93 @@
|
|
|
2041
3847
|
// by history.snap() per the Global Constraint ("every structural mutation
|
|
2042
3848
|
// -> history snap").
|
|
2043
3849
|
|
|
2044
|
-
//
|
|
2045
|
-
//
|
|
2046
|
-
|
|
2047
|
-
|
|
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) {
|
|
3850
|
+
// S1: the caret's own li block, or null. closestLiBlock() replaces the old
|
|
3851
|
+
// "nearest ancestor <li>, never crossing root" walk — a flat block has no
|
|
3852
|
+
// list ancestor to cross, and the block boundary is the natural stop.
|
|
3853
|
+
function caretLiBlock() {
|
|
2056
3854
|
const sel = window.getSelection();
|
|
2057
3855
|
if (!sel.rangeCount) return null;
|
|
2058
|
-
return
|
|
3856
|
+
return closestLiBlock(sel.getRangeAt(0).startContainer);
|
|
2059
3857
|
}
|
|
2060
3858
|
|
|
2061
3859
|
// Task 4 fix (review, Critical): a NON-collapsed selection whose two
|
|
2062
|
-
// boundary points resolve to DIFFERENT
|
|
3860
|
+
// boundary points resolve to DIFFERENT list blocks (or either resolves
|
|
2063
3861
|
// to none) has no defined split semantics under the brief's caret-based
|
|
2064
3862
|
// Enter contract — splitListItemAtCaret()'s Range extractContents() was
|
|
2065
|
-
// anchored only to the START container's own
|
|
3863
|
+
// anchored only to the START container's own item, so a cross-item
|
|
2066
3864
|
// selection silently deleted whatever the selection covered in the OTHER
|
|
2067
3865
|
// item(s) before the (wrong) split ran. True only for a genuinely
|
|
2068
3866
|
// cross-item selection; a same-item multi-character selection is still a
|
|
2069
3867
|
// normal (delete-then-split) Enter, handled by splitListItemAtCaret()
|
|
2070
3868
|
// itself.
|
|
2071
|
-
function selectionSpansMultipleListItems(
|
|
3869
|
+
function selectionSpansMultipleListItems() {
|
|
2072
3870
|
const sel = window.getSelection();
|
|
2073
3871
|
if (!sel.rangeCount) return false;
|
|
2074
3872
|
const range = sel.getRangeAt(0);
|
|
2075
3873
|
if (range.collapsed) return false;
|
|
2076
|
-
const startLi =
|
|
2077
|
-
const endLi =
|
|
3874
|
+
const startLi = closestLiBlock(range.startContainer);
|
|
3875
|
+
const endLi = closestLiBlock(range.endContainer);
|
|
2078
3876
|
return !startLi || !endLi || startLi !== endLi;
|
|
2079
3877
|
}
|
|
2080
3878
|
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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;
|
|
3879
|
+
// S1 replacement for directNestedListOf(): "does this item own children?" is
|
|
3880
|
+
// now "is the NEXT block an li at a strictly greater indent?". A block's
|
|
3881
|
+
// children are, by construction of the flat renderer's DFS walk, the
|
|
3882
|
+
// contiguous run of deeper blocks immediately following it.
|
|
3883
|
+
// RULING F-Q's guard reads this.
|
|
3884
|
+
function liBlockHasChildren(blockEl) {
|
|
3885
|
+
const self = liAttrs(blockEl);
|
|
3886
|
+
if (!self) return false;
|
|
3887
|
+
const all = allBlockEls();
|
|
3888
|
+
const i = all.indexOf(blockEl);
|
|
3889
|
+
if (i < 0) return false;
|
|
3890
|
+
const next = liAttrs(all[i + 1]);
|
|
3891
|
+
return !!next && next.indent > self.indent;
|
|
2105
3892
|
}
|
|
2106
3893
|
|
|
2107
3894
|
// Task 8: the per-li edit surface (`<div class="ed-li-text">`, see
|
|
2108
|
-
// lib/md2doc.js's renderEditModeList) that holds
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
3895
|
+
// lib/md2doc.js's renderEditModeList) that holds this block's own inline
|
|
3896
|
+
// content, or null. S1 removed the pre-per-li "fall back to the <li> itself"
|
|
3897
|
+
// shape: a flat li block ALWAYS has exactly one .ed-li-text child (the
|
|
3898
|
+
// renderer emits it unconditionally, and splitListItemAtCaret() below
|
|
3899
|
+
// reproduces it), so a null here means the element is not a list block at
|
|
3900
|
+
// all — which callers must not paper over.
|
|
3901
|
+
function liTextEl(blockEl) {
|
|
3902
|
+
for (let i = 0; i < blockEl.childNodes.length; i++) {
|
|
3903
|
+
const c = blockEl.childNodes[i];
|
|
2114
3904
|
if (c.nodeType === 1 && c.nodeName === 'DIV' &&
|
|
2115
3905
|
c.classList && c.classList.contains('ed-li-text')) return c;
|
|
2116
3906
|
}
|
|
2117
|
-
return
|
|
3907
|
+
return null;
|
|
2118
3908
|
}
|
|
2119
3909
|
|
|
2120
|
-
// Task 8: the non-editable checkbox chrome (spec §6) of `
|
|
2121
|
-
function liCheckEl(
|
|
2122
|
-
for (let i = 0; i <
|
|
2123
|
-
const c =
|
|
3910
|
+
// Task 8: the non-editable checkbox chrome (spec §6) of `blockEl`, if any.
|
|
3911
|
+
function liCheckEl(blockEl) {
|
|
3912
|
+
for (let i = 0; i < blockEl.childNodes.length; i++) {
|
|
3913
|
+
const c = blockEl.childNodes[i];
|
|
2124
3914
|
if (c.nodeType === 1 && c.nodeName === 'SPAN' &&
|
|
2125
3915
|
c.classList && c.classList.contains('ed-li-check')) return c;
|
|
2126
3916
|
}
|
|
2127
3917
|
return null;
|
|
2128
3918
|
}
|
|
2129
3919
|
|
|
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
3920
|
// 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.
|
|
2149
|
-
//
|
|
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
|
|
3921
|
+
// means the item's OWN text is blank. Owning children does NOT disqualify it:
|
|
3922
|
+
// row 3's press OUTDENTS the item and the subtree travels with it, so there
|
|
2152
3923
|
// is nothing to orphan. Spec §4 / §11 row 3 state the outdent with no
|
|
2153
|
-
// carve-out, so gating row 3 on
|
|
2154
|
-
//
|
|
3924
|
+
// carve-out, so gating row 3 on "has no children" silently sent an empty item
|
|
3925
|
+
// that owned a sublist to the row-1 SPLIT instead (two empty items, the
|
|
2155
3926
|
// subtree re-parented under the second).
|
|
2156
3927
|
//
|
|
2157
|
-
// "Own text" is the `.ed-li-text` surface's text, which
|
|
2158
|
-
//
|
|
2159
|
-
// normalised to a space so a surface
|
|
2160
|
-
// counts as blank, and a bare
|
|
2161
|
-
//
|
|
2162
|
-
function liOwnTextIsBlank(
|
|
2163
|
-
const textEl = liTextEl(
|
|
2164
|
-
if (textEl
|
|
2165
|
-
|
|
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() === '';
|
|
3928
|
+
// "Own text" is the `.ed-li-text` surface's text, which in the flat model is
|
|
3929
|
+
// the item's own content by construction — descendants are separate blocks,
|
|
3930
|
+
// not descendants of this element. NBSP is normalised to a space so a surface
|
|
3931
|
+
// holding only a non-breaking space still counts as blank, and a bare
|
|
3932
|
+
// placeholder <br> counts too (its textContent is '').
|
|
3933
|
+
function liOwnTextIsBlank(blockEl) {
|
|
3934
|
+
const textEl = liTextEl(blockEl);
|
|
3935
|
+
if (!textEl) return false;
|
|
3936
|
+
return textEl.textContent.replace(/\u00a0/g, ' ').trim() === '';
|
|
2174
3937
|
}
|
|
2175
3938
|
|
|
2176
3939
|
// Task 8 / RULING F-U: true when `el` holds nothing any serializer would emit
|
|
@@ -2197,35 +3960,36 @@
|
|
|
2197
3960
|
return true;
|
|
2198
3961
|
}
|
|
2199
3962
|
|
|
2200
|
-
// Splits `
|
|
2201
|
-
// extractContents()-based pattern wrapRangeIn() above already uses,
|
|
2202
|
-
// inline formatting (a caret mid-<strong>, say) splits cleanly instead of
|
|
3963
|
+
// Splits `blockEl` into two sibling BLOCKS at the caret via Range surgery —
|
|
3964
|
+
// the same extractContents()-based pattern wrapRangeIn() above already uses,
|
|
3965
|
+
// so inline formatting (a caret mid-<strong>, say) splits cleanly instead of
|
|
2203
3966
|
// being torn.
|
|
2204
3967
|
//
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2209
|
-
//
|
|
2210
|
-
//
|
|
2211
|
-
//
|
|
2212
|
-
//
|
|
2213
|
-
//
|
|
2214
|
-
// converting the tail half to a plain
|
|
3968
|
+
// The caret lives inside the block's own `<div class="ed-li-text">` surface,
|
|
3969
|
+
// so the tail range runs to the END OF THAT DIV and the new block gets a
|
|
3970
|
+
// .ed-li-text div of its own to hold it. The provisional block deliberately
|
|
3971
|
+
// carries NO data-block-id: list-md.js reads it only for per-li unsupported
|
|
3972
|
+
// ATTRIBUTION, and the very next commitListStructure() + re-render replaces
|
|
3973
|
+
// this element with a real, server-numbered block anyway. It DOES carry
|
|
3974
|
+
// data-block-type / data-list-type / data-task / data-indent, all of which
|
|
3975
|
+
// serializeBlocks() reads to emit the line, plus a `.ed-li-marker` and (for a
|
|
3976
|
+
// task item) an unchecked `.ed-li-check`, so splitting a task item yields
|
|
3977
|
+
// another task item rather than silently converting the tail half to a plain
|
|
3978
|
+
// bullet.
|
|
2215
3979
|
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
2219
|
-
// "whichever half it follows in DOM order" rule the
|
|
2220
|
-
// and
|
|
2221
|
-
// subtree).
|
|
3980
|
+
// S1: the subtree needs no handling at all. A block's children are the
|
|
3981
|
+
// contiguous deeper blocks that FOLLOW it, and the new block is inserted
|
|
3982
|
+
// directly after the old one — so the subtree lands under the NEW item for
|
|
3983
|
+
// free, which is the same "whichever half it follows in DOM order" rule the
|
|
3984
|
+
// nested version had and what the spec's Enter contract requires.
|
|
2222
3985
|
//
|
|
2223
|
-
// Returns the new
|
|
2224
|
-
// surface (nothing mutated).
|
|
2225
|
-
function splitListItemAtCaret(
|
|
3986
|
+
// Returns the new block element, or null when the caret is not inside
|
|
3987
|
+
// `blockEl`'s own surface (nothing mutated).
|
|
3988
|
+
function splitListItemAtCaret(blockEl) {
|
|
2226
3989
|
const sel = window.getSelection();
|
|
2227
3990
|
if (!sel.rangeCount) return null;
|
|
2228
|
-
const textEl = liTextEl(
|
|
3991
|
+
const textEl = liTextEl(blockEl);
|
|
3992
|
+
if (!textEl) return null;
|
|
2229
3993
|
const range = sel.getRangeAt(0).cloneRange();
|
|
2230
3994
|
// Containment is checked BEFORE deleteContents() so the refusal below is a
|
|
2231
3995
|
// true no-op rather than "the selection was deleted, then we gave up".
|
|
@@ -2235,220 +3999,141 @@
|
|
|
2235
3999
|
tailRange.setStart(range.startContainer, range.startOffset);
|
|
2236
4000
|
tailRange.setEnd(textEl, textEl.childNodes.length);
|
|
2237
4001
|
const tailFrag = tailRange.extractContents();
|
|
2238
|
-
|
|
2239
|
-
const
|
|
4002
|
+
|
|
4003
|
+
const newBlock = document.createElement('div');
|
|
4004
|
+
newBlock.className = 'ed-block';
|
|
4005
|
+
newBlock.setAttribute('data-block-type', 'li');
|
|
4006
|
+
newBlock.setAttribute('data-list-type', blockEl.getAttribute('data-list-type') || 'ul');
|
|
4007
|
+
newBlock.setAttribute('data-task', blockEl.getAttribute('data-task') === '1' ? '1' : '0');
|
|
4008
|
+
setBlockIndent(newBlock, Number(blockEl.getAttribute('data-indent')) || 0);
|
|
4009
|
+
const marker = document.createElement('span');
|
|
4010
|
+
marker.className = 'ed-li-marker';
|
|
4011
|
+
marker.setAttribute('aria-hidden', 'true');
|
|
4012
|
+
newBlock.appendChild(marker);
|
|
4013
|
+
const check = liCheckEl(blockEl);
|
|
2240
4014
|
if (check) {
|
|
2241
4015
|
const newCheck = check.cloneNode(false);
|
|
2242
4016
|
newCheck.setAttribute('data-checked', '0');
|
|
2243
4017
|
newCheck.setAttribute('aria-checked', 'false');
|
|
2244
|
-
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
4018
|
+
newBlock.appendChild(newCheck);
|
|
4019
|
+
}
|
|
4020
|
+
const newText = document.createElement('div');
|
|
4021
|
+
newText.className = 'ed-li-text';
|
|
4022
|
+
newText.appendChild(tailFrag);
|
|
4023
|
+
newBlock.appendChild(newText);
|
|
4024
|
+
blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
|
|
4025
|
+
return newBlock;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
// S1: removing an item is removing its element. There is no list container
|
|
4029
|
+
// left to clean up when it empties — the run simply has one member fewer.
|
|
4030
|
+
// Callers must have established that the block owns no children (see
|
|
4031
|
+
// liBlockHasChildren()); the flat model would otherwise leave orphans behind
|
|
4032
|
+
// at a deeper indent than anything above them.
|
|
4033
|
+
function removeListItem(blockEl) {
|
|
4034
|
+
blockEl.parentNode.removeChild(blockEl);
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
// The contiguous run of blocks immediately after `blockEl` whose indent is
|
|
4038
|
+
// strictly greater than `indent` — i.e. that item's subtree in the flat
|
|
4039
|
+
// model. Used by the outdent below, which moves the subtree with its owner.
|
|
4040
|
+
function subtreeBlocksAfter(blockEl, indent) {
|
|
4041
|
+
const all = allBlockEls();
|
|
4042
|
+
const i = all.indexOf(blockEl);
|
|
4043
|
+
const out = [];
|
|
4044
|
+
if (i < 0) return out;
|
|
4045
|
+
for (let k = i + 1; k < all.length; k++) {
|
|
4046
|
+
const a = liAttrs(all[k]);
|
|
4047
|
+
if (!a || a.indent <= indent) break;
|
|
4048
|
+
out.push(all[k]);
|
|
4049
|
+
}
|
|
4050
|
+
return out;
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
// Tab (spec §3.5, 清單項 row): the item's indent goes up by one, clamped by
|
|
4054
|
+
// spec §3.4 rule 1 — "the previous block's indent + 1", with an upper bound of
|
|
4055
|
+
// 0 when the previous block is not a list item. Returns true iff something
|
|
4056
|
+
// actually moved, so the caller only commits on a real change.
|
|
2279
4057
|
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
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).
|
|
4058
|
+
// ── Task 6: THE SUBTREE NO LONGER FOLLOWS ──────────────────────────────
|
|
4059
|
+
// Up to v2.10.2 an indent dragged the item's whole subtree with it. That was
|
|
4060
|
+
// never a decision, it was an artifact: pre-S1 Tab re-parented the <li> and
|
|
4061
|
+
// the nested <ul> travelled inside it, and the flat rewrite reproduced the
|
|
4062
|
+
// observable behaviour rather than changing two things at once.
|
|
2306
4063
|
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
//
|
|
2314
|
-
// outcomes that is a pure re-parenting: no item's rendered indent column
|
|
2315
|
-
// changes except `li`'s own.
|
|
4064
|
+
// Spec §3.5 says the opposite, and the user chose it explicitly after seeing
|
|
4065
|
+
// both behaviours side by side: the children keep their own indent and
|
|
4066
|
+
// therefore become the operated item's SIBLINGS. So '- a / - b / (2sp)- b1'
|
|
4067
|
+
// + Tab on b now gives '- a / (2sp)- b / (2sp)- b1', not
|
|
4068
|
+
// '- a / (2sp)- b / (4sp)- b1'. The row-5 scenario in
|
|
4069
|
+
// test/editor-client-runtime.test.js pinned the old expectation and was
|
|
4070
|
+
// migrated with this change.
|
|
2316
4071
|
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
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.
|
|
4072
|
+
// Nothing replaces the subtree walk: leaving the children alone IS the new
|
|
4073
|
+
// rule, and §3.4's clamp confirms it is legal (a child at old+1 sits under a
|
|
4074
|
+
// parent that is now also at old+1, whose bound is old+2).
|
|
2343
4075
|
//
|
|
2344
|
-
//
|
|
2345
|
-
//
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
//
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
const
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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
|
|
4076
|
+
// S1: this is integer arithmetic on data-indent, not re-parenting. It
|
|
4077
|
+
// reproduces the pre-S1 semantics exactly — there, an item with no previous
|
|
4078
|
+
// <li> SIBLING could not indent, and in the flat model an item whose previous
|
|
4079
|
+
// BLOCK is shallower-or-equal gets the same answer via the clamp (a deeper
|
|
4080
|
+
// previous block belongs to the previous sibling's subtree and only raises
|
|
4081
|
+
// the bound, which the +1 never reaches).
|
|
4082
|
+
//
|
|
4083
|
+
// RULING F-T is now structural rather than defensive: the moved item keeps
|
|
4084
|
+
// its own data-list-type, so it can no longer be silently re-markered by
|
|
4085
|
+
// being appended into a sublist of the other type.
|
|
4086
|
+
function indentListItem(blockEl) {
|
|
4087
|
+
const self = liAttrs(blockEl);
|
|
4088
|
+
if (!self) return false;
|
|
4089
|
+
// Rule (d): the first item of a LIST has nothing above it to nest under.
|
|
4090
|
+
// Without this the §3.4 clamp would happily read the previous list's last
|
|
4091
|
+
// item as "the previous block" and indent this one underneath it, merging
|
|
4092
|
+
// two lists the user never asked to join. Pre-S1 this fell out of
|
|
4093
|
+
// `previousElementSibling` being null inside the item's own <ul>.
|
|
4094
|
+
if (self.listStart) return false;
|
|
4095
|
+
const all = allBlockEls();
|
|
4096
|
+
const i = all.indexOf(blockEl);
|
|
4097
|
+
if (i < 0) return false;
|
|
4098
|
+
const prev = liAttrs(all[i - 1]);
|
|
4099
|
+
const max = prev ? prev.indent + 1 : 0;
|
|
4100
|
+
const next = Math.min(self.indent + 1, max);
|
|
4101
|
+
if (next === self.indent) return false;
|
|
4102
|
+
setBlockIndent(blockEl, next);
|
|
2378
4103
|
return true;
|
|
2379
4104
|
}
|
|
2380
4105
|
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
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
|
-
}
|
|
4106
|
+
// Shift+Tab (spec §11 row 6 / §3.5, user-verified against Notion): the item
|
|
4107
|
+
// rises one level, its OWN subtree rises with it, and its former FOLLOWING
|
|
4108
|
+
// same-level siblings keep their indent — which is exactly what makes them
|
|
4109
|
+
// its children afterwards. Top level (indent 0) -> no-op (row 8).
|
|
4110
|
+
//
|
|
4111
|
+
// S1: the three clauses of §3.5 collapse into two integer writes. Clause 2
|
|
4112
|
+
// (the "adoption" the pre-S1 version implemented by physically re-parenting
|
|
4113
|
+
// every follower into a freshly-created sublist of the matching type) is now
|
|
4114
|
+
// free: leaving the followers' indent alone IS the adoption, and because they
|
|
4115
|
+
// keep their own data-list-type they can no longer come back re-markered as
|
|
4116
|
+
// '1.' — the third silent failure mode the nested implementation had to
|
|
4117
|
+
// hand-guard against. Clause 3 is the same subtree walk indentListItem()
|
|
4118
|
+
// above uses, with delta -1.
|
|
4119
|
+
function outdentListItem(blockEl) {
|
|
4120
|
+
const self = liAttrs(blockEl);
|
|
4121
|
+
if (!self || self.indent === 0) return false;
|
|
4122
|
+
const subtree = subtreeBlocksAfter(blockEl, self.indent);
|
|
4123
|
+
setBlockIndent(blockEl, self.indent - 1);
|
|
4124
|
+
subtree.forEach((el) => {
|
|
4125
|
+
const a = liAttrs(el);
|
|
4126
|
+
if (a) setBlockIndent(el, Math.max(0, a.indent - 1));
|
|
4127
|
+
});
|
|
4128
|
+
return true;
|
|
2444
4129
|
}
|
|
2445
4130
|
|
|
2446
4131
|
// ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
|
|
2447
4132
|
// Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
|
|
2448
4133
|
// 8. Structurally different from Task 4's whole-list handleListKeydown()
|
|
2449
|
-
//
|
|
2450
|
-
// one big contenteditable and the commit waited for focusout.
|
|
2451
|
-
// is its own block AND its own surface, so a provisional
|
|
4134
|
+
// (deleted in S1 along with the rest of the legacy 'list' surface): there, a
|
|
4135
|
+
// key mutated one big contenteditable and the commit waited for focusout.
|
|
4136
|
+
// Here each li is its own block AND its own surface, so a provisional block is not a real
|
|
2452
4137
|
// block until the run is committed and re-rendered — every mutating key
|
|
2453
4138
|
// therefore commits immediately (spec §3: "any structural change
|
|
2454
4139
|
// re-serializes the whole run → one line-range replace"), which is also what
|
|
@@ -2458,9 +4143,17 @@
|
|
|
2458
4143
|
// convertEmptyTopLevelLiToParagraph() below and RULING F-J).
|
|
2459
4144
|
|
|
2460
4145
|
// Shared refusal for a structural key on a run that cannot round-trip —
|
|
2461
|
-
// see listRunSupportsStructuralEdit().
|
|
2462
|
-
|
|
2463
|
-
|
|
4146
|
+
// see listRunSupportsStructuralEdit(). Also reused (with an explicit
|
|
4147
|
+
// `message` override) by the two blockOwnsNoLine() guards near
|
|
4148
|
+
// openRawEditor() / deleteBlockViaGutter() above — a block that owns no
|
|
4149
|
+
// source line at all is a different reason to refuse than "the run holds
|
|
4150
|
+
// an unsupported format", so it gets its own wording, but there is still
|
|
4151
|
+
// only ONE dismiss-only banner helper: two near-identical refusal
|
|
4152
|
+
// functions in this closure collided once already (Task 4 fix round 1)
|
|
4153
|
+
// and shadowed each other silently (last-declaration-wins), so the
|
|
4154
|
+
// no-source-line callers pass their own text instead of a second function.
|
|
4155
|
+
function refuseStructuralListEdit(message) {
|
|
4156
|
+
showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
|
|
2464
4157
|
}
|
|
2465
4158
|
|
|
2466
4159
|
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
@@ -2477,23 +4170,35 @@
|
|
|
2477
4170
|
// single undo op. Observed granularity (asserted in
|
|
2478
4171
|
// test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
|
|
2479
4172
|
// paragraph without popping the stack, Ctrl+Z #2 reverts the li removal.
|
|
2480
|
-
async function convertEmptyTopLevelLiToParagraph(
|
|
4173
|
+
async function convertEmptyTopLevelLiToParagraph(runEls, li) {
|
|
2481
4174
|
// Both captured BEFORE the mutation. The range, because removing the run's
|
|
2482
4175
|
// last item leaves commitListStructure() nothing to derive it from. The
|
|
2483
4176
|
// anchor, because a removal never shifts a block that starts ahead of it,
|
|
2484
4177
|
// and the run's re-serialization only rewrites lines from the run's own
|
|
2485
4178
|
// start onward — so this startLine survives the commit and is the stable
|
|
2486
4179
|
// handle back to that block (ids are re-derived by every render).
|
|
2487
|
-
const range =
|
|
4180
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
|
|
2488
4181
|
const liBlock = blocks.find((b) => b.id === Number(li.getAttribute('data-block-id')));
|
|
4182
|
+
// `b.id !== liBlock.id` is not redundant: a block that owns no source line
|
|
4183
|
+
// has endLine === startLine - 1, so it satisfies `endLine < startLine`
|
|
4184
|
+
// AGAINST ITSELF and would be picked as its own predecessor. Unreachable
|
|
4185
|
+
// today (such a block is never armed, so this row-3 path cannot start on
|
|
4186
|
+
// one) but it is the same class of bug as the arming one above, and the
|
|
4187
|
+
// guard costs nothing.
|
|
2489
4188
|
const precedingBlock = liBlock
|
|
2490
|
-
? blocks.filter((b) => b.endLine < liBlock.startLine).pop()
|
|
4189
|
+
? blocks.filter((b) => b.id !== liBlock.id && b.endLine < liBlock.startLine).pop()
|
|
2491
4190
|
: null;
|
|
4191
|
+
// S1: the post-mutation span is the pre-mutation one minus the removed
|
|
4192
|
+
// block. It cannot be re-derived from `li` afterwards (the element is
|
|
4193
|
+
// detached), and re-deriving it from a survivor would be wrong for the
|
|
4194
|
+
// last-item case, where the answer must be an EMPTY span (serializes to
|
|
4195
|
+
// '', which is what takes commitListStructure()'s range-removal path).
|
|
4196
|
+
const survivors = runEls.filter((el) => el !== li);
|
|
2492
4197
|
mutateListRun(() => removeListItem(li));
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
|
|
2496
|
-
|
|
4198
|
+
// No `mutatedEl`: `li` is not IN `survivors`, and every block that is was
|
|
4199
|
+
// left exactly as the file has it.
|
|
4200
|
+
const ok = await commitListStructure(survivors, null, false,
|
|
4201
|
+
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
2497
4202
|
if (!ok) return;
|
|
2498
4203
|
// Nothing precedes the removal point (the list opened the document):
|
|
2499
4204
|
// commitBlockInsertion() can only insert BELOW an existing block, so the
|
|
@@ -2517,32 +4222,52 @@
|
|
|
2517
4222
|
snapBurstIfActive(editEl, 'br');
|
|
2518
4223
|
return true;
|
|
2519
4224
|
}
|
|
2520
|
-
|
|
2521
|
-
if (!root) return true;
|
|
2522
|
-
// The CARET's li, not editEl's: a run has one editable surface per item,
|
|
4225
|
+
// The CARET's block, not editEl's: a run has one editable surface per item,
|
|
2523
4226
|
// and the caret can legitimately sit in a different one than the burst was
|
|
2524
|
-
// opened on (placing a Range inside another
|
|
2525
|
-
// focus).
|
|
4227
|
+
// opened on (placing a Range inside another item's surface does not move
|
|
4228
|
+
// focus). closestLiBlock(editEl) is the fallback when the selection is
|
|
2526
4229
|
// absent or outside the run.
|
|
2527
|
-
const li =
|
|
4230
|
+
const li = caretLiBlock() || closestLiBlock(editEl);
|
|
2528
4231
|
if (!li) return true;
|
|
4232
|
+
// S1: the commit span, re-derived AFTER each mutation below (an indent
|
|
4233
|
+
// change can move a block between runs). This one is the PRE-mutation span
|
|
4234
|
+
// the gates run against.
|
|
4235
|
+
let run = listRunOf(li);
|
|
4236
|
+
if (!run.length) return true;
|
|
2529
4237
|
|
|
2530
4238
|
if (e.key === 'Tab') {
|
|
2531
|
-
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
//
|
|
2538
|
-
|
|
4239
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
4240
|
+
// a hard-wrapped item is allowed to be the target here — see
|
|
4241
|
+
// listRunSupportsStructuralEdit()'s deviation note for the measurement.
|
|
4242
|
+
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
4243
|
+
refuseStructuralListEdit(); return true;
|
|
4244
|
+
}
|
|
4245
|
+
// Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
|
|
4246
|
+
// children keep their indent and become its siblings. Shift+Tab:
|
|
4247
|
+
// outdentListItem() raises it one level, takes its own subtree with it,
|
|
4248
|
+
// and adopts its former following same-level siblings. Both return false
|
|
4249
|
+
// at their respective boundary (no previous sibling / already top level),
|
|
4250
|
+
// which is a complete no-op — nothing mutated, nothing committed, file
|
|
4251
|
+
// byte-identical.
|
|
4252
|
+
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
4253
|
+
const changed = mutateListRun(() => {
|
|
4254
|
+
if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
|
|
4255
|
+
applyIndentClamp(run, li, oldIndent);
|
|
4256
|
+
return true;
|
|
4257
|
+
});
|
|
2539
4258
|
if (!changed) return true;
|
|
2540
|
-
|
|
4259
|
+
run = listRunOf(li);
|
|
4260
|
+
// Column-only: nothing's CONTENT moved, so every block in the span —
|
|
4261
|
+
// the target included — is a bystander whose source bytes must come
|
|
4262
|
+
// back untouched. Built once and shared with runLineOfBlock() below,
|
|
4263
|
+
// which indexes into the lines this very map decides.
|
|
4264
|
+
const carry = bystanderCarryOver(run, null);
|
|
4265
|
+
commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
2541
4266
|
return true;
|
|
2542
4267
|
}
|
|
2543
4268
|
|
|
2544
4269
|
// Enter.
|
|
2545
|
-
if (selectionSpansMultipleListItems(
|
|
4270
|
+
if (selectionSpansMultipleListItems()) {
|
|
2546
4271
|
// Refuse rather than silently deleting the spanned content — no
|
|
2547
4272
|
// mutation, no commit, no banner. Collapse to the end of the selection
|
|
2548
4273
|
// so a repeat Enter (now a plain caret) behaves predictably.
|
|
@@ -2559,7 +4284,11 @@
|
|
|
2559
4284
|
}
|
|
2560
4285
|
return true;
|
|
2561
4286
|
}
|
|
2562
|
-
|
|
4287
|
+
// Enter's target is the caret's own item in every one of its three
|
|
4288
|
+
// outcomes (split, empty-outdent, convert-to-paragraph) — each rewrites
|
|
4289
|
+
// that item's own line range, which is exactly what a hard-wrapped item
|
|
4290
|
+
// refuses (spec §4.1).
|
|
4291
|
+
if (!listRunSupportsStructuralEdit(run, li)) { refuseStructuralListEdit(); return true; }
|
|
2563
4292
|
if (liOwnTextIsBlank(li)) {
|
|
2564
4293
|
// Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
|
|
2565
4294
|
// (adoption included). RULING F-Q: an item that OWNS a sublist takes this
|
|
@@ -2579,11 +4308,15 @@
|
|
|
2579
4308
|
// quirk), and only once the outdent above has actually happened, since a
|
|
2580
4309
|
// refused press must leave the DOM byte-identical.
|
|
2581
4310
|
const textEl = liTextEl(li);
|
|
2582
|
-
if (textEl
|
|
4311
|
+
if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
|
|
2583
4312
|
return true;
|
|
2584
4313
|
});
|
|
2585
4314
|
if (outdented) {
|
|
2586
|
-
|
|
4315
|
+
run = listRunOf(li);
|
|
4316
|
+
// `li` is the mutated block (the outdent may have cleared its
|
|
4317
|
+
// surface), so its own bytes are the DOM's, not the file's.
|
|
4318
|
+
const carry = bystanderCarryOver(run, li);
|
|
4319
|
+
commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
2587
4320
|
return true;
|
|
2588
4321
|
}
|
|
2589
4322
|
// Already at top level, so this is row 3's "next press converts the block
|
|
@@ -2594,14 +4327,22 @@
|
|
|
2594
4327
|
// did not touch. Refuse instead — a complete no-op (nothing mutated,
|
|
2595
4328
|
// nothing committed, burst left open) until the user empties or moves the
|
|
2596
4329
|
// children themselves.
|
|
2597
|
-
if (
|
|
2598
|
-
convertEmptyTopLevelLiToParagraph(
|
|
4330
|
+
if (liBlockHasChildren(li)) return true;
|
|
4331
|
+
convertEmptyTopLevelLiToParagraph(run, li);
|
|
2599
4332
|
return true;
|
|
2600
4333
|
}
|
|
2601
4334
|
// Row 1: split at the caret; the caret goes to the START of the new block.
|
|
2602
4335
|
const newLi = mutateListRun(() => splitListItemAtCaret(li));
|
|
2603
4336
|
if (!newLi) return true;
|
|
2604
|
-
|
|
4337
|
+
run = listRunOf(newLi);
|
|
4338
|
+
// `li` had its text CUT IN TWO in the DOM; replaying its source would put
|
|
4339
|
+
// the whole of it back and duplicate the half that moved into `newLi`.
|
|
4340
|
+
// Named explicitly rather than leaning on the dirty-burst exclusion: the
|
|
4341
|
+
// caret can sit in a different item than the burst was opened on (see
|
|
4342
|
+
// where `li` is derived above), and then the burst names the wrong block.
|
|
4343
|
+
// `newLi` is provisional (no data-block-id) and excludes itself.
|
|
4344
|
+
const carry = bystanderCarryOver(run, li);
|
|
4345
|
+
commitListStructure(run, runLineOfBlock(run, newLi, carry), false, { carryOver: carry });
|
|
2605
4346
|
return true;
|
|
2606
4347
|
}
|
|
2607
4348
|
|
|
@@ -2646,11 +4387,11 @@
|
|
|
2646
4387
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
2647
4388
|
const block = blocks.find((b) => b.id === blockId);
|
|
2648
4389
|
if (!block) return;
|
|
2649
|
-
const history = historyLib.createBurstHistory(() => tableEl
|
|
4390
|
+
const history = historyLib.createBurstHistory(() => burstBaselineHtml(tableEl), { debounceMs: 400 });
|
|
2650
4391
|
history.start();
|
|
2651
4392
|
currentBurst = {
|
|
2652
4393
|
blockEl, editEl: tableEl, blockId, blockType: 'table',
|
|
2653
|
-
depth: null, original: tableEl
|
|
4394
|
+
depth: null, original: burstBaselineHtml(tableEl), history,
|
|
2654
4395
|
activeCellEl: cellEl,
|
|
2655
4396
|
};
|
|
2656
4397
|
selToolbarEditEl = cellEl;
|
|
@@ -2787,7 +4528,7 @@
|
|
|
2787
4528
|
// auto-remove an untouched pristine table insert, so a chained undo()
|
|
2788
4529
|
// must be skipped or it cascades one op too far.
|
|
2789
4530
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
2790
|
-
tableEl
|
|
4531
|
+
burstBaselineHtml(tableEl) === burst.original);
|
|
2791
4532
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
2792
4533
|
}
|
|
2793
4534
|
|
|
@@ -2935,9 +4676,19 @@
|
|
|
2935
4676
|
allRowsOf(tableEl).forEach((row) => {
|
|
2936
4677
|
const isHeader = row === headerRowOf(tableEl);
|
|
2937
4678
|
const cell = document.createElement(isHeader ? 'th' : 'td');
|
|
4679
|
+
// 新欄是空的,narrow 與空儲存格一致;classifyColumns() 是 render 時
|
|
4680
|
+
// 的啟發式,編輯器無法重跑,下一次 commit 全量重繪時才會重算。
|
|
4681
|
+
cell.className = 'cell-narrow';
|
|
2938
4682
|
const ref = row.cells[colIndex];
|
|
2939
4683
|
row.insertBefore(cell, ref ? ref.nextSibling : null);
|
|
2940
4684
|
});
|
|
4685
|
+
const cg = tableEl.querySelector('colgroup');
|
|
4686
|
+
if (cg) {
|
|
4687
|
+
const col = document.createElement('col');
|
|
4688
|
+
col.className = 'col-narrow';
|
|
4689
|
+
const ref = cg.children[colIndex];
|
|
4690
|
+
cg.insertBefore(col, ref ? ref.nextSibling : null);
|
|
4691
|
+
}
|
|
2941
4692
|
}
|
|
2942
4693
|
|
|
2943
4694
|
function deleteColumn(tableEl, colIndex) {
|
|
@@ -2945,6 +4696,8 @@
|
|
|
2945
4696
|
const cell = row.cells[colIndex];
|
|
2946
4697
|
if (cell) row.removeChild(cell);
|
|
2947
4698
|
});
|
|
4699
|
+
const cg = tableEl.querySelector('colgroup');
|
|
4700
|
+
if (cg && cg.children[colIndex]) cg.removeChild(cg.children[colIndex]);
|
|
2948
4701
|
}
|
|
2949
4702
|
|
|
2950
4703
|
// Mirrors table-md.js's own cellAlign() (that file can't require this one
|
|
@@ -3043,23 +4796,61 @@
|
|
|
3043
4796
|
// The old `!document.body.contains(tableEl)` check treated that as
|
|
3044
4797
|
// "table's gone" and bailed out, silently discarding the insert/delete/
|
|
3045
4798
|
// align/drop the caller was trying to perform. Capture this table's OWN
|
|
3046
|
-
//
|
|
4799
|
+
// identity FIRST and, same stale-node recovery the focusin listener uses
|
|
3047
4800
|
// above, re-resolve the LIVE table by it when the original reference no
|
|
3048
4801
|
// longer resolves — returning that live element (which callers below now
|
|
3049
4802
|
// use in place of their own now-possibly-stale `tableEl`) instead of a
|
|
3050
4803
|
// bare boolean, so a caller can never accidentally keep operating on the
|
|
3051
4804
|
// detached node it started with.
|
|
4805
|
+
//
|
|
4806
|
+
// S1 (Critical): that recovery used to key on `data-block-id`, which is
|
|
4807
|
+
// NOT stable across the very commit it is recovering from. blockmap.js
|
|
4808
|
+
// renumbers ids 0..n-1 in document order on EVERY render (`nextId =
|
|
4809
|
+
// {v:0}`), so a resolved burst that changes the NUMBER of blocks before
|
|
4810
|
+
// this table (e.g. a dirty raw-edit textarea whose source splits one
|
|
4811
|
+
// paragraph into two) shifts every later id down — and the id captured
|
|
4812
|
+
// here then names a DIFFERENT block. `classList.contains('ed-wys-table')`
|
|
4813
|
+
// was the only guard, and every table passes it, so the gesture silently
|
|
4814
|
+
// rewrote an untouched neighbouring table (a row drag even promoted one of
|
|
4815
|
+
// its body rows to its header). Re-resolve by `startLine` instead — see
|
|
4816
|
+
// blockElAtLine()'s own comment: a startLine captured before a commit is
|
|
4817
|
+
// the only stable way back to a specific block afterwards — and then
|
|
4818
|
+
// verify the block we landed on really is the same table (header cell
|
|
4819
|
+
// count, header cell text, row count) before handing it to a caller that
|
|
4820
|
+
// is about to mutate it. A commit that shifts this table's own start line
|
|
4821
|
+
// (an insert/delete ABOVE it) leaves nothing to resolve; returning null
|
|
4822
|
+
// drops the gesture, which is the conservative half of the trade — never
|
|
4823
|
+
// mutating the wrong table beats completing every gesture.
|
|
4824
|
+
function tableIdentityOf(tableEl) {
|
|
4825
|
+
const headerRow = tableEl ? headerRowOf(tableEl) : null;
|
|
4826
|
+
if (!headerRow) return null;
|
|
4827
|
+
return allRowsOf(tableEl).length + '' +
|
|
4828
|
+
Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('');
|
|
4829
|
+
}
|
|
3052
4830
|
async function ensureTableBurstOpen(tableEl) {
|
|
3053
4831
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
|
|
3054
4832
|
const blockEl = tableEl.closest('.ed-block');
|
|
3055
|
-
const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
4833
|
+
const blockId = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
|
|
4834
|
+
const block = blockId != null ? blocks.find((b) => b.id === blockId) : null;
|
|
4835
|
+
const startLine = block ? block.startLine : null;
|
|
4836
|
+
const identity = tableIdentityOf(tableEl);
|
|
3056
4837
|
const ok = await switchAwayFrom();
|
|
3057
4838
|
if (!ok) return null;
|
|
3058
4839
|
let liveTableEl = tableEl;
|
|
3059
4840
|
if (!document.body.contains(tableEl)) {
|
|
3060
|
-
const liveBlockEl =
|
|
4841
|
+
const liveBlockEl = startLine != null ? blockElAtLine(startLine) : null;
|
|
3061
4842
|
liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
3062
|
-
|
|
4843
|
+
// T7: both refusals used to `return null` in silence — the drag, the
|
|
4844
|
+
// insert, the alignment change simply did not happen and nothing on
|
|
4845
|
+
// screen said why, which is indistinguishable from a broken control.
|
|
4846
|
+
// Dropping is still the right answer (see the S1 comment above); saying
|
|
4847
|
+
// nothing was not.
|
|
4848
|
+
if (!liveTableEl || !liveTableEl.classList || !liveTableEl.classList.contains('ed-wys-table')) {
|
|
4849
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
|
|
4850
|
+
}
|
|
4851
|
+
if (identity == null || tableIdentityOf(liveTableEl) !== identity) {
|
|
4852
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
|
|
4853
|
+
}
|
|
3063
4854
|
}
|
|
3064
4855
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
|
|
3065
4856
|
const cell = tableCellsOf(liveTableEl)[0];
|
|
@@ -3187,15 +4978,22 @@
|
|
|
3187
4978
|
// just above the column while hovering it — see the "Notion-style grip
|
|
3188
4979
|
// handles" section below) selects the column: every th/td in it gets
|
|
3189
4980
|
// '.ed-te-hl' and a floating menu (delete / align-cycle) appears. Clicking
|
|
3190
|
-
// a row's grip handle (the vertical 6-dot affordance shown
|
|
3191
|
-
//
|
|
3192
|
-
// delete-only menu
|
|
4981
|
+
// a row's grip handle (the vertical 6-dot affordance shown at the row's
|
|
4982
|
+
// own left edge while hovering it) selects the row the same way, with a
|
|
4983
|
+
// delete-only menu — except on the HEADER row, whose grip click only
|
|
4984
|
+
// highlights (a header can never be deleted, so its menu would be empty;
|
|
4985
|
+
// see the header-grip branch in the pointerup handler below).
|
|
4986
|
+
// User-acceptance feedback on the ORIGINAL design (an
|
|
3193
4987
|
// invisible TE_EDGE_PX=8 proximity zone hugging the table's raw top/left
|
|
3194
4988
|
// pixel edge, with no visible affordance at all) was that it was
|
|
3195
4989
|
// unusably small — pixel-hunting a click target with no visual cue. The
|
|
3196
4990
|
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
3197
|
-
// user can actually see and aim for. Both grips are
|
|
3198
|
-
//
|
|
4991
|
+
// user can actually see and aim for. Both grips are overlay elements
|
|
4992
|
+
// `position: fixed`-appended to document.body — never DOM CHILDREN of a
|
|
4993
|
+
// contenteditable cell, even though the row grip's inner half visually
|
|
4994
|
+
// overlaps one (it straddles the table's left border; see the geometry
|
|
4995
|
+
// note in the "Notion-style row/column grip handles" section below). So —
|
|
4996
|
+
// unlike the old zones, which
|
|
3199
4997
|
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
3200
4998
|
// delegated `pointerdown` listener below to preventDefault() there to
|
|
3201
4999
|
// stop native caret placement from stealing the click — a grip's own
|
|
@@ -3203,19 +5001,29 @@
|
|
|
3203
5001
|
// focus put now (same "keep focus put" idiom buildTableInsertBubble()
|
|
3204
5002
|
// documents for the hover-insert bubbles above).
|
|
3205
5003
|
//
|
|
3206
|
-
// Row drag starts from the SAME row grip
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
5004
|
+
// Row drag starts from the SAME row grip. EVERY row is draggable and every
|
|
5005
|
+
// row gets a grip, the header <tr> included (spec §3.10/§4.6: in markdown a
|
|
5006
|
+
// table's first row IS its header, so position alone decides header
|
|
5007
|
+
// identity — dragging a data row above the header PROMOTES it, and the old
|
|
5008
|
+
// header becomes a data row). EVERY row's grip — the header's included —
|
|
5009
|
+
// uses the SAME geometry: centred on the table's left border, vertically
|
|
5010
|
+
// centred on its own row. There is no per-row-type special case.
|
|
5011
|
+
//
|
|
5012
|
+
// After a small movement threshold (distinguishing "click to open the
|
|
3209
5013
|
// menu" from "press-and-drag"), a drop-indicator line tracks the pointer
|
|
3210
|
-
// between
|
|
3211
|
-
//
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
// suppressTableFocusout
|
|
3218
|
-
//
|
|
5014
|
+
// between rows — including an "above the header" boundary; releasing
|
|
5015
|
+
// performs a PURE MOVE via rebuildTableSections(), which re-lays the same
|
|
5016
|
+
// <tr>/<th>/<td> nodes across thead/tbody so whichever row ended up first
|
|
5017
|
+
// becomes the header row. That rebuild necessarily DETACHES the cell that
|
|
5018
|
+
// currently holds focus, so Chromium fires a synchronous focusout
|
|
5019
|
+
// mid-mutation — exactly the quirk tableBurstUndo()/tableBurstRedo()'s
|
|
5020
|
+
// innerHTML-snapshot restore has to guard. performRowDrop() therefore
|
|
5021
|
+
// wraps it in `suppressTableFocusout` (the third of the flag's four sites)
|
|
5022
|
+
// and puts focus back explicitly via restoreTableFocus(). An earlier
|
|
5023
|
+
// revision moved the <tr> with a plain `insertBefore()`, which reparents
|
|
5024
|
+
// in one synchronous step, never blurs, and needed no guard — the header
|
|
5025
|
+
// promotion requirement is what retired that. The menu's delete ops (which
|
|
5026
|
+
// DO remove nodes) sidestep the same hazard a different way — see
|
|
3219
5027
|
// refocusAwayFromColumn()/refocusAwayFromRow() below.
|
|
3220
5028
|
//
|
|
3221
5029
|
// Both the menu (delete/align) and the drag's DOM move are burst
|
|
@@ -3247,7 +5055,18 @@
|
|
|
3247
5055
|
let teHighlightEls = [];
|
|
3248
5056
|
|
|
3249
5057
|
function clearEdgeHighlight() {
|
|
3250
|
-
|
|
5058
|
+
// classList.remove() leaves a dangling `class=""` behind on any element
|
|
5059
|
+
// the renderer/armEditables() emitted with NO class of its own — a
|
|
5060
|
+
// col-default cell in a table that was never armed — and
|
|
5061
|
+
// that residue is a real innerHTML diff that defeats resolveBurst()'s
|
|
5062
|
+
// zero-edit guard, canonically rewriting a table the user only
|
|
5063
|
+
// highlighted. Same fix cancelTeDrag()/the pointerup drag branch already
|
|
5064
|
+
// apply to the dragged row's own class — drop the attribute when it
|
|
5065
|
+
// goes empty, here too.
|
|
5066
|
+
teHighlightEls.forEach((el) => {
|
|
5067
|
+
el.classList.remove('ed-te-hl');
|
|
5068
|
+
if (!el.className) el.removeAttribute('class');
|
|
5069
|
+
});
|
|
3251
5070
|
teHighlightEls = [];
|
|
3252
5071
|
}
|
|
3253
5072
|
|
|
@@ -3259,10 +5078,22 @@
|
|
|
3259
5078
|
});
|
|
3260
5079
|
}
|
|
3261
5080
|
|
|
5081
|
+
// S3 (Important): the class goes on the row's CELLS, exactly like
|
|
5082
|
+
// highlightColumn() above — never on the `<tr>`. A `<tr>` highlight paints
|
|
5083
|
+
// nothing the user can see: `th { background: #f6f8fa }` and the sticky
|
|
5084
|
+
// first column's `background: #ffffff` are painted by the CELLS, which sit
|
|
5085
|
+
// ABOVE the row box, and `!important` does not let a rule on one element
|
|
5086
|
+
// beat an opaque background painted by a different element on top of it.
|
|
5087
|
+
// The header row (all-`<th>`) rendered as ZERO pixels changed, and the
|
|
5088
|
+
// body-row case lost its first cell to the sticky rule for the same
|
|
5089
|
+
// reason — while the Esc gate below still counted that invisible state as
|
|
5090
|
+
// "a selection is open" and ate the user's next Escape.
|
|
3262
5091
|
function highlightRow(rowEl) {
|
|
3263
5092
|
clearEdgeHighlight();
|
|
3264
|
-
rowEl.
|
|
3265
|
-
|
|
5093
|
+
Array.prototype.slice.call(rowEl.cells).forEach((cell) => {
|
|
5094
|
+
cell.classList.add('ed-te-hl');
|
|
5095
|
+
teHighlightEls.push(cell);
|
|
5096
|
+
});
|
|
3266
5097
|
}
|
|
3267
5098
|
|
|
3268
5099
|
function hideTableEdgeMenu() {
|
|
@@ -3460,9 +5291,12 @@
|
|
|
3460
5291
|
// Two singleton overlay elements — same "one shared node, repositioned
|
|
3461
5292
|
// via getBoundingClientRect(), never one per row/column" Global Constraint
|
|
3462
5293
|
// the hover-insert bubbles above follow. `rowGrip` is a vertical 6-dot
|
|
3463
|
-
// handle shown
|
|
3464
|
-
//
|
|
3465
|
-
//
|
|
5294
|
+
// handle shown at the LEFT EDGE of whichever row the pointer is currently
|
|
5295
|
+
// hovering any cell of — EVERY row, the header included (spec §3.10: the
|
|
5296
|
+
// header is draggable too, since position alone decides header identity;
|
|
5297
|
+
// only its CLICK differs, highlighting instead of opening the
|
|
5298
|
+
// delete-only menu) — positioned identically on every row, header
|
|
5299
|
+
// included; `colGrip` is a horizontal 6-dot handle
|
|
3466
5300
|
// shown just ABOVE whichever column the pointer is hovering (every
|
|
3467
5301
|
// column, header included — the column menu's delete/align both apply to
|
|
3468
5302
|
// header cells too). Built once by buildTableGrip() below and driven by
|
|
@@ -3470,10 +5304,21 @@
|
|
|
3470
5304
|
// listener (wired near the bottom of this file) that already drives
|
|
3471
5305
|
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
3472
5306
|
// contract this reuses.
|
|
3473
|
-
// Review fix (P0-a)
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
5307
|
+
// Review fix (P0-a) + user acceptance (uniform geometry): neither grip is
|
|
5308
|
+
// separated from the table, and both use the SAME rule — the grip's own
|
|
5309
|
+
// CENTRELINE coincides with the table border it belongs to, so its hit
|
|
5310
|
+
// rect straddles that border by half its own size on each side. COLUMN
|
|
5311
|
+
// grip: centred on the table's TOP edge. ROW grip: centred on the table's
|
|
5312
|
+
// LEFT edge. An earlier revision insetted the row grip fully INSIDE the
|
|
5313
|
+
// table to dodge the block's own gutter ⠿; that was reverted — the ⠿
|
|
5314
|
+
// occupies only the block's top ~20px while a row grip sits at its own
|
|
5315
|
+
// row's mid-height, so the two never actually intersect, and the inset put
|
|
5316
|
+
// the grip on top of the first cell's TEXT (user-acceptance defect). The
|
|
5317
|
+
// gutter is given its own room in CSS instead (`.content { padding-left }`
|
|
5318
|
+
// plus `.ed-handle/.ed-insert { left: -36px }`, both edit-mode-only — see
|
|
5319
|
+
// lib/md2doc.js), so the straddling grip reaches only into the first
|
|
5320
|
+
// cell's PADDING, never its text.
|
|
5321
|
+
// Either way the grip's hit rect DOES overlap the insert bubble's hit
|
|
3477
5322
|
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
3478
5323
|
// own axis). Non-intersection via rect separation is no longer possible
|
|
3479
5324
|
// or required. Instead, "insert-bubble click is never eaten by the grip"
|
|
@@ -3501,15 +5346,16 @@
|
|
|
3501
5346
|
b.hidden = true;
|
|
3502
5347
|
// Same "keep the burst's focus/selection intact across the click" idiom
|
|
3503
5348
|
// buildTableInsertBubble() above documents — without this, the grip
|
|
3504
|
-
// (
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
5349
|
+
// (a document.body child, never a descendant of the table it is pinned
|
|
5350
|
+
// to, however far inside the table's own edge it is drawn) stealing
|
|
5351
|
+
// focus on mousedown would fire a focusout on the currently-focused
|
|
5352
|
+
// cell BEFORE this gesture's own `pointerdown` handler below even runs.
|
|
3507
5353
|
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
3508
5354
|
document.body.appendChild(b);
|
|
3509
5355
|
return b;
|
|
3510
5356
|
}
|
|
3511
5357
|
const rowGrip = buildTableGrip('ed-te-grip-row', '列選項 / 拖曳排序');
|
|
3512
|
-
const colGrip = buildTableGrip('ed-te-grip-col', '欄選項');
|
|
5358
|
+
const colGrip = buildTableGrip('ed-te-grip-col', '欄選項 / 拖曳排序');
|
|
3513
5359
|
|
|
3514
5360
|
// Which table/row/column the two grips are CURRENTLY pinned to — updated
|
|
3515
5361
|
// by updateTableEdgeGrips() below, read back by hitTestGrip() at
|
|
@@ -3526,23 +5372,36 @@
|
|
|
3526
5372
|
function hideTableGrips() {
|
|
3527
5373
|
rowGrip.hidden = true;
|
|
3528
5374
|
colGrip.hidden = true;
|
|
5375
|
+
// Task 8 fix round 1 (Minor 4): clear BOTH grips' dragging visual — a
|
|
5376
|
+
// row and a column can each wear `ed-te-grip-dragging` mid-drag, and
|
|
5377
|
+
// this is reachable while one is in flight (e.g. a burst resolution
|
|
5378
|
+
// calling hideTableGrips() mid-gesture), so leaving colGrip out was
|
|
5379
|
+
// exactly the row/col asymmetry this task exists to remove.
|
|
3529
5380
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
5381
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3530
5382
|
gripRowTableEl = null;
|
|
3531
5383
|
gripRowEl = null;
|
|
3532
5384
|
gripColTableEl = null;
|
|
3533
5385
|
gripColIndex = null;
|
|
3534
5386
|
}
|
|
3535
5387
|
|
|
3536
|
-
// Bug fix (user acceptance): grips
|
|
3537
|
-
//
|
|
3538
|
-
//
|
|
3539
|
-
//
|
|
3540
|
-
//
|
|
3541
|
-
//
|
|
3542
|
-
//
|
|
3543
|
-
//
|
|
3544
|
-
//
|
|
3545
|
-
//
|
|
5388
|
+
// Bug fix (user acceptance) — history: grips were originally BOTH
|
|
5389
|
+
// border-straddling (P0-a), and were visible on hover but UNREACHABLE by a
|
|
5390
|
+
// real pointer. Root cause — a pointer travelling from inside a cell
|
|
5391
|
+
// toward a grip necessarily crossed a ~10px corridor OUTSIDE the table's
|
|
5392
|
+
// border on the way (the grip's own left/top half). The naive hit test
|
|
5393
|
+
// below ("on a cell, or hide") hid the grip the instant the pointer left
|
|
5394
|
+
// the table/cell — BEFORE it ever reached the grip — so only a
|
|
5395
|
+
// teleporting click (every existing test used pressReleaseAt()/
|
|
5396
|
+
// gripCenter(), which jump straight to the grip's own coordinates) could
|
|
5397
|
+
// ever land on it; a real mouse gesture could not.
|
|
5398
|
+
//
|
|
5399
|
+
// Current geometry (uniform, both axes): BOTH grips straddle their own
|
|
5400
|
+
// table border — the row grip half outside / half inside the table's LEFT
|
|
5401
|
+
// border, the column grip half above / half below its TOP border. So the
|
|
5402
|
+
// corridor-crossing bug described above applies to BOTH of them exactly as
|
|
5403
|
+
// originally described, and both keep-zones below cover the corresponding
|
|
5404
|
+
// outside-the-border corridor.
|
|
3546
5405
|
//
|
|
3547
5406
|
// Review fix (Important, first pass over-permissive): the first version of
|
|
3548
5407
|
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
@@ -3553,17 +5412,19 @@
|
|
|
3553
5412
|
// reviewer live-reproduced) kept row 1's grip visible at its now-stale
|
|
3554
5413
|
// position instead of hiding it. Fixed by gating the keep-zone on the
|
|
3555
5414
|
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
3556
|
-
// pointInColGripZone() below) instead of the whole table
|
|
3557
|
-
//
|
|
3558
|
-
//
|
|
3559
|
-
//
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3562
|
-
//
|
|
3563
|
-
//
|
|
3564
|
-
//
|
|
3565
|
-
//
|
|
3566
|
-
//
|
|
5415
|
+
// pointInColGripZone() below) instead of the whole table.
|
|
5416
|
+
//
|
|
5417
|
+
// What each keep-zone covers TODAY: both keep the ORIGINAL corridor shape,
|
|
5418
|
+
// mirrored per axis. pointInRowGripZone() is the union of (the row grip's
|
|
5419
|
+
// own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel rounding) and (the
|
|
5420
|
+
// straight strip between the grip's own LEFT edge and the table's LEFT
|
|
5421
|
+
// border, y clamped to the anchor ROW's own vertical extent, padded).
|
|
5422
|
+
// pointInColGripZone() is the same with the axes swapped: the strip
|
|
5423
|
+
// between the grip's own top edge and the table's top border, x clamped to
|
|
5424
|
+
// the anchor COLUMN's own horizontal extent, padded.
|
|
5425
|
+
// A pointer outside either grip's own zone is a genuine exit and still
|
|
5426
|
+
// hides the grip via hideTableGrips(), same as before. Neither fix touches
|
|
5427
|
+
// either grip's size or z-index, so the click-priority guarantee (bubble
|
|
3567
5428
|
// z-index:8 > grip z-index:7 — see the comment above buildTableGrip())
|
|
3568
5429
|
// is unaffected — this only changes how long an already-shown grip STAYS
|
|
3569
5430
|
// visible, never where it sits. See
|
|
@@ -3582,8 +5443,11 @@
|
|
|
3582
5443
|
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
3583
5444
|
const gr = rowGrip.getBoundingClientRect();
|
|
3584
5445
|
if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
|
|
3585
|
-
|
|
5446
|
+
// grip 跨在表格左邊界上,所以「從儲存格走向 grip」必定經過邊界外側的
|
|
5447
|
+
// 那半個 grip 寬度。走廊=從 grip 自己的左緣到表格左緣,垂直方向夾在
|
|
5448
|
+
// 所錨定那一列的上下緣(加 TE_GRIP_ZONE_PAD_PX 的次像素寬容)。
|
|
3586
5449
|
const rowRect = gripRowEl.getBoundingClientRect();
|
|
5450
|
+
const tableRect = gripRowTableEl.getBoundingClientRect();
|
|
3587
5451
|
return x >= gr.left && x <= tableRect.left &&
|
|
3588
5452
|
y >= rowRect.top - TE_GRIP_ZONE_PAD_PX && y <= rowRect.bottom + TE_GRIP_ZONE_PAD_PX;
|
|
3589
5453
|
}
|
|
@@ -3612,9 +5476,12 @@
|
|
|
3612
5476
|
// pointer (Event#target) at those coordinates, same contract
|
|
3613
5477
|
// updateTableInsertBubbles() above uses.
|
|
3614
5478
|
function updateTableEdgeGrips(x, y, target) {
|
|
3615
|
-
// Both grips
|
|
3616
|
-
//
|
|
3617
|
-
//
|
|
5479
|
+
// Both grips are `position: fixed` overlays appended to document.body
|
|
5480
|
+
// (same as the hover-insert bubbles) rather than descendants of the
|
|
5481
|
+
// table — both PAINT half outside it (the column grip straddling the top
|
|
5482
|
+
// border, the row grip straddling the left border). So the moment the
|
|
5483
|
+
// real pointer crosses from a cell onto
|
|
5484
|
+
// the grip itself, `target` is the grip and is no
|
|
3618
5485
|
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
3619
5486
|
// Without this guard, that transition would hit the "nothing found"
|
|
3620
5487
|
// branches below and hide the very grip the pointer just moved onto —
|
|
@@ -3645,9 +5512,16 @@
|
|
|
3645
5512
|
const headerRow = headerRowOf(tableEl);
|
|
3646
5513
|
const colIndex = colIndexOf(cellEl);
|
|
3647
5514
|
|
|
3648
|
-
// Row grip:
|
|
3649
|
-
//
|
|
3650
|
-
|
|
5515
|
+
// Row grip: every row, including the header — the first row of a
|
|
5516
|
+
// markdown table IS the header, so any row must be draggable to the
|
|
5517
|
+
// top to become it. ONE position rule for all of them (user acceptance:
|
|
5518
|
+
// 「grip 位置都一樣」) — centred on the table's left border, vertically
|
|
5519
|
+
// centred on its own row; no header special case. The
|
|
5520
|
+
// one exception is a header-only table (no body rows): its single row
|
|
5521
|
+
// is thead's only row, and dragging it away would empty the thead —
|
|
5522
|
+
// serializeTable() would degrade it and the user's table would vanish
|
|
5523
|
+
// from the page. Withhold the grip there instead.
|
|
5524
|
+
if (rowEl && (rowEl !== headerRow || bodyRowsOf(tableEl).length > 0)) {
|
|
3651
5525
|
gripRowTableEl = tableEl;
|
|
3652
5526
|
gripRowEl = rowEl;
|
|
3653
5527
|
const r = rowEl.getBoundingClientRect();
|
|
@@ -3655,8 +5529,8 @@
|
|
|
3655
5529
|
// (20x28) — offsetWidth/Height read 0 while `hidden` (display: none)
|
|
3656
5530
|
// is still true on the FIRST show of a hover session, before the
|
|
3657
5531
|
// `hidden = false` assignment below takes effect.
|
|
3658
|
-
const gh = rowGrip.offsetHeight || 28;
|
|
3659
5532
|
const gw = rowGrip.offsetWidth || 20;
|
|
5533
|
+
const gh = rowGrip.offsetHeight || 28;
|
|
3660
5534
|
rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
|
|
3661
5535
|
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
3662
5536
|
rowGrip.hidden = false;
|
|
@@ -3693,10 +5567,16 @@
|
|
|
3693
5567
|
if (!target || !target.closest) return null;
|
|
3694
5568
|
if (target.closest('.ed-te-grip-row')) {
|
|
3695
5569
|
if (!gripRowTableEl || !gripRowEl || !document.body.contains(gripRowEl)) return null;
|
|
3696
|
-
return { kind: 'row', tableEl: gripRowTableEl, rowEl: gripRowEl,
|
|
5570
|
+
return { kind: 'row', tableEl: gripRowTableEl, rowEl: gripRowEl,
|
|
5571
|
+
isHeader: gripRowEl === headerRowOf(gripRowTableEl) };
|
|
3697
5572
|
}
|
|
3698
5573
|
if (target.closest('.ed-te-grip-col')) {
|
|
3699
|
-
|
|
5574
|
+
// The `document.body.contains()` detach check mirrors the row branch
|
|
5575
|
+
// above: the two axes are symmetric gestures now (Task 8 gave the
|
|
5576
|
+
// column its own drag), so a stale `gripColTableEl` left pointing at a
|
|
5577
|
+
// table that a rerenderAll()/burst-resolution already swapped out must
|
|
5578
|
+
// fail the hit-test rather than hand a detached node to performColDrop().
|
|
5579
|
+
if (!gripColTableEl || gripColIndex == null || !document.body.contains(gripColTableEl)) return null;
|
|
3700
5580
|
return { kind: 'col', tableEl: gripColTableEl, colIndex: gripColIndex };
|
|
3701
5581
|
}
|
|
3702
5582
|
return null;
|
|
@@ -3710,30 +5590,46 @@
|
|
|
3710
5590
|
teDropIndicator.hidden = true;
|
|
3711
5591
|
document.body.appendChild(teDropIndicator);
|
|
3712
5592
|
|
|
3713
|
-
// Nearest
|
|
3714
|
-
//
|
|
3715
|
-
//
|
|
3716
|
-
//
|
|
3717
|
-
//
|
|
5593
|
+
// Nearest row-drop target for `clientY` — a discriminated union:
|
|
5594
|
+
// {mode:'above-header', y} | {mode:'before-row', rowIndex, y} |
|
|
5595
|
+
// {mode:'append', y}. `rowIndex` is an ordinal into allRowsOf(). The
|
|
5596
|
+
// header row is now itself a candidate boundary (spec §4.6: the first row
|
|
5597
|
+
// of a markdown table IS its header, so promoting any row to first place
|
|
5598
|
+
// has to go through an explicit "above the header" target) — `<=` on the
|
|
5599
|
+
// header's own midline gives "released exactly on the header's centre" an
|
|
5600
|
+
// unambiguous home in `above-header` rather than leaving it to float
|
|
5601
|
+
// between two branches.
|
|
5602
|
+
//
|
|
5603
|
+
// spec §4.6:`<=` 讓「釋放點恰在表頭正中央」有明確歸屬(above-header)。
|
|
5604
|
+
// 既有那條綠測試釋放在表頭列的 bottom,落在 FALSE 側,仍走下面的
|
|
5605
|
+
// body 中線鏈、仍得 3,1,2,因此不需要改它的期望值。
|
|
3718
5606
|
function nearestRowDropTarget(tableEl, clientY) {
|
|
5607
|
+
const headerRow = headerRowOf(tableEl);
|
|
5608
|
+
if (headerRow) {
|
|
5609
|
+
const hr = headerRow.getBoundingClientRect();
|
|
5610
|
+
if (clientY <= hr.top + hr.height / 2) return { mode: 'above-header', y: hr.top };
|
|
5611
|
+
}
|
|
5612
|
+
const all = allRowsOf(tableEl);
|
|
3719
5613
|
const rows = bodyRowsOf(tableEl);
|
|
3720
5614
|
for (let i = 0; i < rows.length; i++) {
|
|
3721
5615
|
const r = rows[i].getBoundingClientRect();
|
|
3722
|
-
if (clientY < r.top + r.height / 2)
|
|
5616
|
+
if (clientY < r.top + r.height / 2) {
|
|
5617
|
+
return { mode: 'before-row', rowIndex: all.indexOf(rows[i]), y: r.top };
|
|
5618
|
+
}
|
|
3723
5619
|
}
|
|
3724
5620
|
const last = rows[rows.length - 1];
|
|
3725
|
-
const headerRow = headerRowOf(tableEl);
|
|
3726
5621
|
const y = last ? last.getBoundingClientRect().bottom
|
|
3727
5622
|
: (headerRow ? headerRow.getBoundingClientRect().bottom : tableEl.getBoundingClientRect().top);
|
|
3728
|
-
return {
|
|
5623
|
+
return { mode: 'append', y };
|
|
3729
5624
|
}
|
|
3730
5625
|
|
|
3731
5626
|
// The in-flight edge-zone pointer gesture (press-then-either-click-or-
|
|
3732
5627
|
// drag), or null between gestures. `hit` is whatever hitTestGrip()
|
|
3733
5628
|
// returned at pointerdown; `dragging` flips true once TE_DRAG_THRESHOLD_PX
|
|
3734
5629
|
// is crossed (row zones only — see the pointermove listener below);
|
|
3735
|
-
// `
|
|
3736
|
-
// moves while dragging
|
|
5630
|
+
// `dropTarget` is filled in by updateDropIndicator() as the pointer
|
|
5631
|
+
// moves while dragging (the nearestRowDropTarget() union — see its own
|
|
5632
|
+
// comment above). `pointerId`/`captureEl` back the pointer-capture
|
|
3737
5633
|
// review fix below — see cancelTeDrag()'s comment for why this gesture
|
|
3738
5634
|
// needs it at all.
|
|
3739
5635
|
let tePointer = null;
|
|
@@ -3741,11 +5637,30 @@
|
|
|
3741
5637
|
function updateDropIndicator(clientY) {
|
|
3742
5638
|
const tableEl = tePointer.hit.tableEl;
|
|
3743
5639
|
const target = nearestRowDropTarget(tableEl, clientY);
|
|
3744
|
-
tePointer.
|
|
5640
|
+
tePointer.dropTarget = target;
|
|
3745
5641
|
const tableRect = tableEl.getBoundingClientRect();
|
|
3746
5642
|
teDropIndicator.style.left = tableRect.left + 'px';
|
|
3747
5643
|
teDropIndicator.style.width = tableRect.width + 'px';
|
|
3748
5644
|
teDropIndicator.style.top = (target.y - 1) + 'px';
|
|
5645
|
+
// The indicator is a shared singleton with the COLUMN drop indicator
|
|
5646
|
+
// (updateColDropIndicator() below), which drives a vertical line and
|
|
5647
|
+
// therefore sets `height` itself — a row drag must write its own back
|
|
5648
|
+
// every time or a prior column drag's height would leak into this one.
|
|
5649
|
+
teDropIndicator.style.height = '3px';
|
|
5650
|
+
}
|
|
5651
|
+
|
|
5652
|
+
// Column-drop counterpart of updateDropIndicator() above: a vertical line
|
|
5653
|
+
// spanning the table's full height at the nearest column boundary, rather
|
|
5654
|
+
// than a horizontal line spanning its width.
|
|
5655
|
+
function updateColDropIndicator(clientX) {
|
|
5656
|
+
const tableEl = tePointer.hit.tableEl;
|
|
5657
|
+
const target = nearestColDropTarget(tableEl, clientX);
|
|
5658
|
+
tePointer.dropTarget = target;
|
|
5659
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
5660
|
+
teDropIndicator.style.left = (target.x - 1) + 'px';
|
|
5661
|
+
teDropIndicator.style.width = '3px';
|
|
5662
|
+
teDropIndicator.style.top = tableRect.top + 'px';
|
|
5663
|
+
teDropIndicator.style.height = tableRect.height + 'px';
|
|
3749
5664
|
}
|
|
3750
5665
|
|
|
3751
5666
|
// Review fix (Critical): best-effort releasePointerCapture() — a no-op
|
|
@@ -3786,42 +5701,188 @@
|
|
|
3786
5701
|
if (!tePointer) return;
|
|
3787
5702
|
releaseTeCapture(tePointer);
|
|
3788
5703
|
if (tePointer.dragging && tePointer.hit && tePointer.hit.rowEl) {
|
|
3789
|
-
tePointer.hit.rowEl
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
5704
|
+
const draggedRowEl = tePointer.hit.rowEl;
|
|
5705
|
+
draggedRowEl.classList.remove('ed-te-row-dragging');
|
|
5706
|
+
// classList.add() CREATED the attribute on a renderer-emitted `<tr>`
|
|
5707
|
+
// (marked's table renderer emits rows with no class at all), and
|
|
5708
|
+
// classList.remove() leaves `class=""` behind rather than dropping
|
|
5709
|
+
// it. That residue is a real innerHTML diff, so resolveBurst()'s
|
|
5710
|
+
// zero-edit guard (`burst.editEl.innerHTML === burst.original`) would
|
|
5711
|
+
// see "edited" for a gesture that changed nothing and canonically
|
|
5712
|
+
// rewrite a hand-padded table. Drop the attribute when it went empty.
|
|
5713
|
+
if (!draggedRowEl.className) draggedRowEl.removeAttribute('class');
|
|
5714
|
+
}
|
|
5715
|
+
// Either grip may still be wearing its "active drag handle" visual (see
|
|
5716
|
+
// the pointermove listener below) — strip both unconditionally, same
|
|
3793
5717
|
// belt-and-braces reasoning as the `ed-te-row-dragging` removal above.
|
|
3794
5718
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
5719
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3795
5720
|
teDropIndicator.hidden = true;
|
|
3796
5721
|
tePointer = null;
|
|
3797
5722
|
}
|
|
3798
5723
|
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
5724
|
+
// 對齊是**欄**屬性(分隔列由表頭 cells 的 style 合成),所以重建前先讀
|
|
5725
|
+
// 出來、重建後套回新的表頭列。只在 align 非 null 時寫,否則會把原本
|
|
5726
|
+
// 沒有 style 的欄寫成 ':---'(spec §4.6 的 attribute-byte 冪等要求)。
|
|
5727
|
+
function columnAlignsOf(tableEl) {
|
|
5728
|
+
const headerRow = headerRowOf(tableEl);
|
|
5729
|
+
if (!headerRow) return [];
|
|
5730
|
+
return Array.prototype.slice.call(headerRow.cells).map(cellStyleAlign);
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
// TH ↔ TD 改名。tag 已經正確就原樣返回——**不重造**,否則屬性落地順序
|
|
5734
|
+
// 會與 armEditables() 不同,innerHTML 隨之改變,於是「原地放回」也會
|
|
5735
|
+
// 被 zero-edit guard 判定為有編輯而 commit(整表 canonical 重寫)。
|
|
5736
|
+
//
|
|
5737
|
+
// 需要重造時,屬性一律照 cell.attributes 的**原順序**逐一複製,不做任何
|
|
5738
|
+
// 特例、也不對「當初是怎麼 arm 的」做任何假設:重造出來的 cell 屬性序列
|
|
5739
|
+
// 與原本那顆逐字相同,byte-identity 要的就只是這個。
|
|
5740
|
+
//
|
|
5741
|
+
// 之所以不能寫死順序(連「contenteditable 一律擺最後」都不行):arm 當下
|
|
5742
|
+
// 的順序**逐欄不同**,取決於 classifyColumns()(lib/md2doc.js)給那一欄的
|
|
5743
|
+
// 等級。col-narrow / col-prose 的 cell renderer 會給 class,armEditables()
|
|
5744
|
+
// 再把 contenteditable 接在後面 → `class, style, contenteditable`;但
|
|
5745
|
+
// col-default 的 cell renderer **完全不給 class**,於是
|
|
5746
|
+
// setAttribute('contenteditable') 先落地、classList.add('ed-wys-cell') 才
|
|
5747
|
+
// 把 class 建出來 → `style, contenteditable, class`。任何固定順序都會弄壞
|
|
5748
|
+
// 其中一種,讓「拖下去再拖回來」列序還原、位元卻沒還原,觸發整表
|
|
5749
|
+
// canonical 重寫。逐字複製對三種等級同時成立。
|
|
5750
|
+
function retagCell(cell, tagName) {
|
|
5751
|
+
if (cell.nodeName === tagName) return cell;
|
|
5752
|
+
const next = document.createElement(tagName.toLowerCase());
|
|
5753
|
+
Array.prototype.slice.call(cell.attributes).forEach((attr) => {
|
|
5754
|
+
next.setAttribute(attr.name, attr.value);
|
|
5755
|
+
});
|
|
5756
|
+
while (cell.firstChild) next.appendChild(cell.firstChild);
|
|
5757
|
+
cell.parentElement.replaceChild(next, cell);
|
|
5758
|
+
return next;
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
// 依 orderedRows 重建 thead/tbody:第一列進 thead(cells 轉 th),其餘
|
|
5762
|
+
// 進 tbody(cells 轉 td)。不變式「thead 恰有一列」由此保證。
|
|
5763
|
+
function rebuildTableSections(tableEl, orderedRows, aligns) {
|
|
5764
|
+
const thead = tableEl.tHead;
|
|
5765
|
+
const tbody = tableEl.tBodies[0];
|
|
5766
|
+
if (!thead || !tbody || orderedRows.length === 0) return;
|
|
5767
|
+
orderedRows.forEach((row, i) => {
|
|
5768
|
+
const wantTag = i === 0 ? 'TH' : 'TD';
|
|
5769
|
+
Array.prototype.slice.call(row.cells).forEach((cell) => retagCell(cell, wantTag));
|
|
5770
|
+
(i === 0 ? thead : tbody).appendChild(row);
|
|
5771
|
+
});
|
|
5772
|
+
const newHeader = orderedRows[0];
|
|
5773
|
+
aligns.forEach((align, i) => {
|
|
5774
|
+
const cell = newHeader.cells[i];
|
|
5775
|
+
if (cell && align) cell.setAttribute('style', 'text-align:' + align);
|
|
5776
|
+
});
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
// 重建必然 detach 持有焦點的儲存格;focusout 被 suppressTableFocusout
|
|
5780
|
+
// 吃掉之後沒有人會把焦點放回去,document.activeElement 會落到 <body>,
|
|
5781
|
+
// 於是 keydown 走不到 handleTableCellKeydown,Ctrl+Z 會落到全域 undo()
|
|
5782
|
+
// 而先 commit 再退。用序位重新解析目標格並真的 focus。
|
|
5783
|
+
function restoreTableFocus(tableEl, cellIndex) {
|
|
5784
|
+
const cells = tableCellsOf(tableEl);
|
|
5785
|
+
const cell = cells[cellIndex >= 0 ? Math.min(cellIndex, cells.length - 1) : 0];
|
|
5786
|
+
if (!cell) return;
|
|
5787
|
+
if (currentBurst && currentBurst.blockType === 'table') currentBurst.activeCellEl = cell;
|
|
5788
|
+
selToolbarEditEl = cell;
|
|
5789
|
+
cell.focus();
|
|
5790
|
+
placeCaretAtEnd(cell);
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5793
|
+
async function performRowDrop(tableEl, rowEl, dropTarget) {
|
|
5794
|
+
// rowEl/dropTarget 都是 pointerdown/拖曳期間抓的;ensureTableBurstOpen()
|
|
5795
|
+
// 可能 resolve 掉別的 block 的 dirty burst 並換掉整片 .content,所以
|
|
5796
|
+
// 先轉成 allRowsOf() 的 ordinal,之後在 live table 上重新定位。
|
|
3808
5797
|
const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
|
|
3809
|
-
const beforeRowIndex = beforeRow ? allRowsOf(tableEl).indexOf(beforeRow) : -1;
|
|
3810
5798
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3811
5799
|
if (!liveTableEl) return;
|
|
3812
5800
|
const liveRows = allRowsOf(liveTableEl);
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
if (
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
5801
|
+
if (rowIndex < 0 || !liveRows[rowIndex]) return;
|
|
5802
|
+
|
|
5803
|
+
let toIndex;
|
|
5804
|
+
if (dropTarget.mode === 'above-header') toIndex = 0;
|
|
5805
|
+
else if (dropTarget.mode === 'append') toIndex = liveRows.length;
|
|
5806
|
+
else toIndex = dropTarget.rowIndex;
|
|
5807
|
+
|
|
5808
|
+
const order = liveRows.slice();
|
|
5809
|
+
const moved = order.splice(rowIndex, 1)[0];
|
|
5810
|
+
order.splice(toIndex > rowIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
5811
|
+
if (order.every((row, i) => row === liveRows[i])) return; // 原地放回:不動 DOM、不 snap
|
|
5812
|
+
|
|
5813
|
+
const aligns = columnAlignsOf(liveTableEl);
|
|
5814
|
+
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
5815
|
+
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
5816
|
+
suppressTableFocusout = true;
|
|
5817
|
+
try {
|
|
5818
|
+
rebuildTableSections(liveTableEl, order, aligns);
|
|
5819
|
+
} finally {
|
|
5820
|
+
suppressTableFocusout = false;
|
|
5821
|
+
}
|
|
5822
|
+
armNewTableCells(liveTableEl);
|
|
5823
|
+
restoreTableFocus(liveTableEl, activeIndex);
|
|
5824
|
+
// 插入路徑早就這樣做了,drop 路徑一直沒有:不清的話 grip 還釘在舊
|
|
5825
|
+
// 座標、teMenuColIndex 指向已經換位的欄。
|
|
5826
|
+
hideTableGrips();
|
|
5827
|
+
hideTableEdgeMenu();
|
|
5828
|
+
// 一律 snap(去重交給 history 自己)。舊碼用 nextSibling 比對判斷
|
|
5829
|
+
// 「有沒有動」,對「第一列與唯一 body 列對調」永遠回 false。
|
|
5830
|
+
currentBurst.history.snap('drag-row');
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5833
|
+
// 欄落點:以表頭各 cell 的中線決定要插到哪個 ordinal 之前。
|
|
5834
|
+
function nearestColDropTarget(tableEl, clientX) {
|
|
5835
|
+
const headerRow = headerRowOf(tableEl);
|
|
5836
|
+
if (!headerRow) return { index: 0, x: tableEl.getBoundingClientRect().left };
|
|
5837
|
+
const cells = Array.prototype.slice.call(headerRow.cells);
|
|
5838
|
+
for (let i = 0; i < cells.length; i++) {
|
|
5839
|
+
const r = cells[i].getBoundingClientRect();
|
|
5840
|
+
if (clientX < r.left + r.width / 2) return { index: i, x: r.left };
|
|
5841
|
+
}
|
|
5842
|
+
const last = cells[cells.length - 1].getBoundingClientRect();
|
|
5843
|
+
return { index: cells.length, x: last.right };
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
// <colgroup> 決定欄寬,且編輯器至今從沒碰過它;不跟著搬的話欄寬會錯位,
|
|
5847
|
+
// 而 table-md.js 不看 colgroup ⇒ 純 markdown 斷言抓不到這個 bug。
|
|
5848
|
+
function reorderColgroup(tableEl, fromIndex, toIndex) {
|
|
5849
|
+
const cg = tableEl.querySelector('colgroup');
|
|
5850
|
+
if (!cg) return;
|
|
5851
|
+
const cols = Array.prototype.slice.call(cg.children);
|
|
5852
|
+
if (!cols[fromIndex]) return;
|
|
5853
|
+
const moved = cols.splice(fromIndex, 1)[0];
|
|
5854
|
+
cols.splice(toIndex > fromIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
5855
|
+
cols.forEach((c) => cg.appendChild(c));
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
async function performColDrop(tableEl, fromIndex, toIndex) {
|
|
5859
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
5860
|
+
if (!liveTableEl) return;
|
|
5861
|
+
if (toIndex === fromIndex || toIndex === fromIndex + 1) return; // 原地放回
|
|
5862
|
+
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
5863
|
+
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
5864
|
+
// 短列必須讓整個操作放棄,不能只跳過那一列(final review M4):原本
|
|
5865
|
+
// `if (!moved) return;` 在 forEach 裡面,短列會被略過、其他列照搬 —— 結果
|
|
5866
|
+
// 是欄位彼此錯位,而每一列的 cell 數量都跟原本一樣,ragged-table guard
|
|
5867
|
+
// 看不出任何異常。寧可整個不動。
|
|
5868
|
+
const dropRows = allRowsOf(liveTableEl);
|
|
5869
|
+
if (!dropRows.length || dropRows.some((row) => !row.cells[fromIndex])) return;
|
|
5870
|
+
suppressTableFocusout = true;
|
|
5871
|
+
try {
|
|
5872
|
+
dropRows.forEach((row) => {
|
|
5873
|
+
const cells = Array.prototype.slice.call(row.cells);
|
|
5874
|
+
const moved = cells.splice(fromIndex, 1)[0];
|
|
5875
|
+
cells.splice(toIndex > fromIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
5876
|
+
cells.forEach((c) => row.appendChild(c));
|
|
5877
|
+
});
|
|
5878
|
+
reorderColgroup(liveTableEl, fromIndex, toIndex);
|
|
5879
|
+
} finally {
|
|
5880
|
+
suppressTableFocusout = false;
|
|
5881
|
+
}
|
|
5882
|
+
restoreTableFocus(liveTableEl, activeIndex);
|
|
5883
|
+
hideTableGrips();
|
|
5884
|
+
hideTableEdgeMenu();
|
|
5885
|
+
currentBurst.history.snap('drag-col');
|
|
3825
5886
|
}
|
|
3826
5887
|
|
|
3827
5888
|
document.addEventListener('pointerdown', (e) => {
|
|
@@ -3844,7 +5905,11 @@
|
|
|
3844
5905
|
// reopening it here.
|
|
3845
5906
|
const isSameSelection = hit && teMenuKind === hit.kind && teMenuTableEl === hit.tableEl &&
|
|
3846
5907
|
(hit.kind === 'col' ? teMenuColIndex === hit.colIndex : teMenuRowEl === hit.rowEl);
|
|
3847
|
-
|
|
5908
|
+
// The highlight can now exist WITHOUT a menu (a header grip's plain
|
|
5909
|
+
// click, above) — so the dismiss condition can no longer gate on
|
|
5910
|
+
// teMenuKind alone, or that highlight would survive until
|
|
5911
|
+
// resolveBurst() instead of clearing on the next click.
|
|
5912
|
+
if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
|
|
3848
5913
|
if (!hit) return;
|
|
3849
5914
|
e.preventDefault();
|
|
3850
5915
|
tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
|
|
@@ -3866,25 +5931,33 @@
|
|
|
3866
5931
|
|
|
3867
5932
|
document.addEventListener('pointermove', (e) => {
|
|
3868
5933
|
if (!tePointer) return;
|
|
3869
|
-
if (tePointer.hit.kind !== 'row'
|
|
5934
|
+
if (tePointer.hit.kind !== 'row' && tePointer.hit.kind !== 'col') return;
|
|
3870
5935
|
if (!tePointer.dragging) {
|
|
3871
5936
|
const dx = e.clientX - tePointer.startX, dy = e.clientY - tePointer.startY;
|
|
3872
5937
|
if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
|
|
3873
5938
|
tePointer.dragging = true;
|
|
3874
5939
|
hideTableEdgeMenu();
|
|
3875
5940
|
hideTableInsertBubbles();
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
5941
|
+
if (tePointer.hit.kind === 'row') {
|
|
5942
|
+
// The column grip hides like the insert bubbles above (it isn't
|
|
5943
|
+
// meaningful mid row-drag); the ROW grip stays visible and switches
|
|
5944
|
+
// to its "dragging" visual (grabbing cursor) — it IS the drag
|
|
5945
|
+
// handle the user is holding, per the brief ("the active grip may
|
|
5946
|
+
// stay as the drag handle visual").
|
|
5947
|
+
colGrip.hidden = true;
|
|
5948
|
+
rowGrip.classList.add('ed-te-grip-dragging');
|
|
5949
|
+
tePointer.hit.rowEl.classList.add('ed-te-row-dragging');
|
|
5950
|
+
} else {
|
|
5951
|
+
// Symmetric for a column drag: the row grip hides, the column grip
|
|
5952
|
+
// itself becomes the drag handle visual.
|
|
5953
|
+
rowGrip.hidden = true;
|
|
5954
|
+
colGrip.classList.add('ed-te-grip-dragging');
|
|
5955
|
+
}
|
|
3884
5956
|
teDropIndicator.hidden = false;
|
|
3885
5957
|
}
|
|
3886
5958
|
e.preventDefault();
|
|
3887
|
-
updateDropIndicator(e.clientY);
|
|
5959
|
+
if (tePointer.hit.kind === 'row') updateDropIndicator(e.clientY);
|
|
5960
|
+
else updateColDropIndicator(e.clientX);
|
|
3888
5961
|
});
|
|
3889
5962
|
|
|
3890
5963
|
document.addEventListener('pointerup', async (e) => {
|
|
@@ -3893,16 +5966,35 @@
|
|
|
3893
5966
|
releaseTeCapture(st);
|
|
3894
5967
|
tePointer = null;
|
|
3895
5968
|
if (st.dragging) {
|
|
3896
|
-
st.hit.
|
|
5969
|
+
if (st.hit.kind === 'row') {
|
|
5970
|
+
st.hit.rowEl.classList.remove('ed-te-row-dragging');
|
|
5971
|
+
// Drop a now-empty `class=""` — see cancelTeDrag()'s own comment
|
|
5972
|
+
// above for why the residue alone defeats the zero-edit guard. (The
|
|
5973
|
+
// grips below need no such treatment: they are OUR elements and
|
|
5974
|
+
// always carry at least 'ed-te-grip-row'/'ed-te-grip-col', so
|
|
5975
|
+
// removing one class can never empty their attribute.)
|
|
5976
|
+
if (!st.hit.rowEl.className) st.hit.rowEl.removeAttribute('class');
|
|
5977
|
+
}
|
|
3897
5978
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
5979
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3898
5980
|
teDropIndicator.hidden = true;
|
|
3899
|
-
await performRowDrop(st.hit.tableEl, st.hit.rowEl, st.
|
|
5981
|
+
if (st.hit.kind === 'row') await performRowDrop(st.hit.tableEl, st.hit.rowEl, st.dropTarget);
|
|
5982
|
+
else await performColDrop(st.hit.tableEl, st.hit.colIndex, st.dropTarget.index);
|
|
3900
5983
|
return;
|
|
3901
5984
|
}
|
|
3902
5985
|
// A plain press-release with no drag threshold crossed: open the menu
|
|
3903
5986
|
// for whatever zone was hit at pointerdown.
|
|
3904
5987
|
if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
|
|
3905
|
-
else
|
|
5988
|
+
else if (st.hit.isHeader) {
|
|
5989
|
+
// The row menu's only item is "delete row", and the header row can
|
|
5990
|
+
// never be deleted — showing it would just be an empty box. A plain
|
|
5991
|
+
// click on the header grip highlights the row instead.
|
|
5992
|
+
// clearEdgeHighlight() runs first so re-clicking a different header
|
|
5993
|
+
// (or a different row's menu having been open) doesn't stack
|
|
5994
|
+
// highlights within the same session.
|
|
5995
|
+
clearEdgeHighlight();
|
|
5996
|
+
highlightRow(st.hit.rowEl);
|
|
5997
|
+
} else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
3906
5998
|
});
|
|
3907
5999
|
|
|
3908
6000
|
// Review fix (Critical): the browser/OS can ABORT a gesture outright —
|
|
@@ -4297,11 +6389,22 @@
|
|
|
4297
6389
|
const checkEl = e.target.closest && e.target.closest('.ed-li-check');
|
|
4298
6390
|
if (checkEl) {
|
|
4299
6391
|
e.preventDefault();
|
|
4300
|
-
const li = checkEl
|
|
6392
|
+
const li = closestLiBlock(checkEl);
|
|
4301
6393
|
if (!li) return;
|
|
4302
|
-
const
|
|
4303
|
-
if (!
|
|
4304
|
-
|
|
6394
|
+
const run = listRunOf(li);
|
|
6395
|
+
if (!run.length) return;
|
|
6396
|
+
// The toggle rewrites the checkbox INSIDE this item's own marker, so
|
|
6397
|
+
// the item is the operation target (spec §4.1) — but by §4.1's own
|
|
6398
|
+
// CRITERION (see listRunSupportsStructuralEdit()'s note) it is a
|
|
6399
|
+
// COLUMN-ONLY operation and therefore not one of the refusals: it
|
|
6400
|
+
// changes no content, no line count and no column at all ('[ ] ' and
|
|
6401
|
+
// '[x] ' are the same width, so §3.4's colDelta is exactly 0). Without
|
|
6402
|
+
// this a hard-wrapped task item — which on a real to-do list is most
|
|
6403
|
+
// of them — answered '此清單含不支援的格式,無法調整結構' to a click
|
|
6404
|
+
// on its own checkbox.
|
|
6405
|
+
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
6406
|
+
refuseStructuralListEdit(); return;
|
|
6407
|
+
}
|
|
4305
6408
|
// Resolve any open burst on another block before mutating. The span
|
|
4306
6409
|
// is non-focusable, so mousedown on it does NOT steal focus — the
|
|
4307
6410
|
// currently-focused surface's focusout never fires, and currentBurst
|
|
@@ -4314,22 +6417,31 @@
|
|
|
4314
6417
|
if (!ok) return;
|
|
4315
6418
|
// Re-find the li and its checkbox after the potential re-render.
|
|
4316
6419
|
const targetLi = targetBlockId
|
|
4317
|
-
? document.querySelector(
|
|
6420
|
+
? document.querySelector(
|
|
6421
|
+
'.ed-block[data-block-type="li"][data-block-id="' + targetBlockId + '"]')
|
|
4318
6422
|
: null;
|
|
4319
6423
|
const targetCheck = targetLi && targetLi.querySelector(':scope > .ed-li-check');
|
|
4320
6424
|
if (!targetCheck) return;
|
|
4321
6425
|
// Re-gate on the post-render DOM in case the burst resolution
|
|
4322
6426
|
// changed the run's supported status.
|
|
4323
|
-
const
|
|
4324
|
-
if (!
|
|
4325
|
-
if (!listRunSupportsStructuralEdit(
|
|
6427
|
+
const targetRun = listRunOf(targetLi);
|
|
6428
|
+
if (!targetRun.length) return;
|
|
6429
|
+
if (!listRunSupportsStructuralEdit(targetRun, targetLi, { columnOnly: true })) {
|
|
6430
|
+
refuseStructuralListEdit(); return;
|
|
6431
|
+
}
|
|
4326
6432
|
// Flip state, then serialize the whole run as one undo op.
|
|
4327
6433
|
const wasChecked = targetCheck.getAttribute('data-checked') === '1';
|
|
4328
6434
|
targetCheck.setAttribute('data-checked', wasChecked ? '0' : '1');
|
|
4329
6435
|
targetCheck.setAttribute('aria-checked', String(!wasChecked));
|
|
4330
6436
|
// focusStartLine = null: a checkbox click is not a caret gesture;
|
|
4331
6437
|
// leave focus wherever the post-commit re-render puts it.
|
|
4332
|
-
|
|
6438
|
+
// Column-only, so no `mutatedEl`: the flipped state travels in the
|
|
6439
|
+
// re-stated MARKER (list-md.js builds '[x] ' as part of it, and
|
|
6440
|
+
// splitSourceMarkers() strips the old one off the replayed line), and
|
|
6441
|
+
// everything after that marker — this item's own continuation lines
|
|
6442
|
+
// included — comes back byte-for-byte.
|
|
6443
|
+
await commitListStructure(targetRun, null, false,
|
|
6444
|
+
{ carryOver: bystanderCarryOver(targetRun, null) });
|
|
4333
6445
|
return;
|
|
4334
6446
|
}
|
|
4335
6447
|
// ⠿ handle: toggles its menu for the block it belongs to. ⠿ menu: its
|
|
@@ -4381,7 +6493,7 @@
|
|
|
4381
6493
|
try {
|
|
4382
6494
|
res = await fetch('/api/save', {
|
|
4383
6495
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
4384
|
-
body: JSON.stringify({ fileId: ED.fileId, content: lines.join(
|
|
6496
|
+
body: JSON.stringify({ fileId: ED.fileId, content: lines.join(EOL), baseMtimeMs: mtimeMs }),
|
|
4385
6497
|
});
|
|
4386
6498
|
} catch (e) {
|
|
4387
6499
|
showBanner('Save failed — network error (' + describeFailure(e) +
|
|
@@ -4467,7 +6579,17 @@
|
|
|
4467
6579
|
// since the cell that was focused (if any) before the menu opened is
|
|
4468
6580
|
// still focused underneath it (the menu's own mousedown preventDefault()
|
|
4469
6581
|
// never stole focus).
|
|
4470
|
-
|
|
6582
|
+
// Final review I1: the condition is `(teMenuKind || teHighlightEls.length)`,
|
|
6583
|
+
// NOT `teMenuKind` alone — the exact same widening the pointerdown dismiss
|
|
6584
|
+
// gate already got, and for the same reason: a header grip's click
|
|
6585
|
+
// deliberately produces a highlight with NO menu (its only menu item,
|
|
6586
|
+
// "delete row", cannot apply to a header), so teMenuKind stays null. Gated
|
|
6587
|
+
// on teMenuKind alone, that Esc fell through to handleTableCellKeydown()'s
|
|
6588
|
+
// own Escape branch -> revertTableBurstAndEnd(), throwing away everything
|
|
6589
|
+
// typed into the burst. With a BODY row's menu open the identical keypress
|
|
6590
|
+
// merely closes the menu, so the header row would have been the one place
|
|
6591
|
+
// where dismissing a selection is destructive.
|
|
6592
|
+
if ((teMenuKind || teHighlightEls.length) && e.key === 'Escape') {
|
|
4471
6593
|
e.preventDefault();
|
|
4472
6594
|
hideTableEdgeMenu();
|
|
4473
6595
|
return;
|
|
@@ -4702,15 +6824,24 @@
|
|
|
4702
6824
|
// without this gate, an active row drag would repaint the + bubble
|
|
4703
6825
|
// (or reposition/re-show the grips over some OTHER row/column the
|
|
4704
6826
|
// cursor is currently dragging across) on TOP of the drop indicator on
|
|
4705
|
-
// every real drag. Explicitly HIDE the insert bubbles and the
|
|
6827
|
+
// every real drag. Explicitly HIDE the insert bubbles and the OTHER
|
|
4706
6828
|
// grip (not just skip recomputing) so anything already showing from
|
|
4707
6829
|
// the moment just before the drag threshold was crossed doesn't linger
|
|
4708
|
-
// stale for the rest of the gesture.
|
|
4709
|
-
//
|
|
4710
|
-
//
|
|
4711
|
-
//
|
|
4712
|
-
//
|
|
4713
|
-
|
|
6830
|
+
// stale for the rest of the gesture. Task 8 fix round 1 (Important 1):
|
|
6831
|
+
// a column drag is now possible too, so "the other grip" is no longer
|
|
6832
|
+
// always the column grip — hide `colGrip` during a row drag, `rowGrip`
|
|
6833
|
+
// during a column drag. The ACTIVE grip (whichever kind is being
|
|
6834
|
+
// dragged) is deliberately left untouched here — the pointermove
|
|
6835
|
+
// listener above already switched it to its "dragging" visual (see
|
|
6836
|
+
// `ed-te-grip-dragging`) as the drag's own handle, and this gate must
|
|
6837
|
+
// not fight that by hiding it or repositioning it onto whatever
|
|
6838
|
+
// row/column the cursor happens to be over.
|
|
6839
|
+
if (tePointer && tePointer.dragging) {
|
|
6840
|
+
hideTableInsertBubbles();
|
|
6841
|
+
if (tePointer.hit.kind === 'row') colGrip.hidden = true;
|
|
6842
|
+
else rowGrip.hidden = true;
|
|
6843
|
+
return;
|
|
6844
|
+
}
|
|
4714
6845
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4715
6846
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4716
6847
|
});
|