@helping-ai-workflow/md2doc 2.12.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +1540 -77
- package/lib/editor/table-md.js +379 -24
- package/lib/md2doc.js +148 -41
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -174,6 +174,291 @@
|
|
|
174
174
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
|
|
178
|
+
//
|
|
179
|
+
// A move is ONE commitRangeEdit over
|
|
180
|
+
// `min(source, destination) .. max(source, destination)`. UndoStack's op is
|
|
181
|
+
// `{startLine, endLine, before, after}` — a SINGLE contiguous line range,
|
|
182
|
+
// with no notion of a source and a destination — so a move written as
|
|
183
|
+
// "removal here, insertion there" is two ops and two Ctrl+Z, which §3.4
|
|
184
|
+
// forbids. For a block that is not a li there is nothing to re-serialize:
|
|
185
|
+
// the lines are relocated VERBATIM, so nothing can be re-escaped and no
|
|
186
|
+
// marker arithmetic is involved.
|
|
187
|
+
//
|
|
188
|
+
// THE BLANK-LINE RULE, which the plan left to this task to define:
|
|
189
|
+
//
|
|
190
|
+
// 1. The block's own lines move byte-for-byte.
|
|
191
|
+
// 2. At the seam it LEAVES, the blank run that surrounded it is absorbed
|
|
192
|
+
// and replaced by exactly ONE blank line — or by none when the block
|
|
193
|
+
// was at the start or the end of the file.
|
|
194
|
+
// 3. At the seam it LANDS in, one blank line is emitted on each side of
|
|
195
|
+
// it whose neighbouring line is not already blank.
|
|
196
|
+
//
|
|
197
|
+
// Neither existing helper is the right tool, and the plan says so:
|
|
198
|
+
// commitRangeRemoval() absorbs exactly one adjacent blank and
|
|
199
|
+
// commitBlockInsertion() always adds a leading one. Each is HALF of a move
|
|
200
|
+
// and the halves do not compose — the pair leaves the document one
|
|
201
|
+
// separator short at the seam that was left and one long at the seam that
|
|
202
|
+
// was landed in, and it is two undo ops.
|
|
203
|
+
//
|
|
204
|
+
// CONSEQUENCE, measured against marked.lexer + blockmap.buildBlockMap on
|
|
205
|
+
// every fixture in test/editor-client-runtime.test.js's T3 section before
|
|
206
|
+
// any byte was pinned: in a document whose blocks are separated by exactly
|
|
207
|
+
// one blank line — everything this editor's own serializer emits — a move
|
|
208
|
+
// is a PERMUTATION OF THE FILE'S LINES. Same lines, same count, same
|
|
209
|
+
// separators.
|
|
210
|
+
//
|
|
211
|
+
// The one exception, and it is deliberate: a document that already jams two
|
|
212
|
+
// blocks onto adjacent lines ('# A\n# B\n# C\n') has no blank at the
|
|
213
|
+
// landing seam to carry, so rule 3 CREATES one and the count goes up. It
|
|
214
|
+
// has to. MEASURED: 'paraA\n# H\n\nparaB\n' with paraA appended and no
|
|
215
|
+
// blank emitted is '# H\n\nparaB\nparaA\n', which marked.lexer answers as
|
|
216
|
+
// ONE paragraph — the move would have eaten a block. The count moves only
|
|
217
|
+
// in that direction, and only at a seam that had no separator to begin
|
|
218
|
+
// with.
|
|
219
|
+
//
|
|
220
|
+
// The final '' of a file that ends in a newline is the TERMINATOR, not a
|
|
221
|
+
// blank separator; `contentLen` below is what keeps rule 2 from eating the
|
|
222
|
+
// file's last newline when the block being moved is the last one.
|
|
223
|
+
//
|
|
224
|
+
// Pure: takes and returns plain data, and lives in the node-visible core
|
|
225
|
+
// beside the other commit helpers.
|
|
226
|
+
function planBlockMove(lines, blocks, src, dest) {
|
|
227
|
+
const isBlank = (s) => s !== undefined && s !== null && s.trim() === '';
|
|
228
|
+
const contentLen = lines.length - (lines.length && lines[lines.length - 1] === '' ? 1 : 0);
|
|
229
|
+
let above = 0;
|
|
230
|
+
while (src.startLine - 1 - above >= 1 && isBlank(lines[src.startLine - 2 - above])) above++;
|
|
231
|
+
let below = 0;
|
|
232
|
+
while (src.endLine + 1 + below <= contentLen && isBlank(lines[src.endLine + below])) below++;
|
|
233
|
+
// The source's own extraction range: its lines plus the whole blank run on
|
|
234
|
+
// either side of it. What goes BACK at that seam is rule 2's single blank,
|
|
235
|
+
// and only when the seam has a block on both sides of it.
|
|
236
|
+
const ds = src.startLine - above;
|
|
237
|
+
const de = src.endLine + below;
|
|
238
|
+
const srcFill = (ds >= 2 && de < contentLen) ? [''] : [];
|
|
239
|
+
const blockLines = lines.slice(src.startLine - 1, src.endLine);
|
|
240
|
+
// `dest === null` is the {mode:'append'} target: the insertion point is
|
|
241
|
+
// one line past the last block's last line.
|
|
242
|
+
const ins = dest ? dest.startLine
|
|
243
|
+
: (blocks.length ? blocks[blocks.length - 1].endLine + 1 : contentLen + 1);
|
|
244
|
+
// Dropped where it already is. `ins === ds` is "on my own top edge";
|
|
245
|
+
// `ins === de + 1` is "on the seam immediately below me" — visually the
|
|
246
|
+
// same place, and the byte no-op the drag's own release-where-you-started
|
|
247
|
+
// case depends on. Every other `ins` inside the extraction range is a
|
|
248
|
+
// blank line, which no block's startLine can name.
|
|
249
|
+
if (ins >= ds && ins <= de + 1) return null;
|
|
250
|
+
const prevLine = ins >= 2 ? lines[ins - 2] : null;
|
|
251
|
+
const nextLine = ins <= lines.length ? lines[ins - 1] : null;
|
|
252
|
+
const lead = (prevLine !== null && !isBlank(prevLine)) ? [''] : [];
|
|
253
|
+
const trail = (nextLine !== null && !isBlank(nextLine)) ? [''] : [];
|
|
254
|
+
const payload = lead.concat(blockLines, trail);
|
|
255
|
+
// ONE range, covering both seams. Everything between them is carried
|
|
256
|
+
// through untouched — which is what makes this a single undo op instead
|
|
257
|
+
// of a removal and an insertion.
|
|
258
|
+
if (ins > de + 1) {
|
|
259
|
+
const after = srcFill.concat(lines.slice(de, ins - 1), payload);
|
|
260
|
+
const newStart = ds + srcFill.length + (ins - 1 - de) + lead.length;
|
|
261
|
+
return { startLine: ds, endLine: ins - 1, after: after,
|
|
262
|
+
newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
|
|
263
|
+
}
|
|
264
|
+
const after = payload.concat(lines.slice(ins - 1, ds - 1), srcFill);
|
|
265
|
+
const newStart = ins + lead.length;
|
|
266
|
+
return { startLine: ins, endLine: de, after: after,
|
|
267
|
+
newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The commit half. `op === null` with `noop` set means the block was
|
|
271
|
+
// released where it already was — a BYTE no-op, not a failure, and
|
|
272
|
+
// deliberately distinct from commitRangeEdit()'s own unchanged-text
|
|
273
|
+
// `op: null` so a caller can tell "nothing to do" from "somebody handed me
|
|
274
|
+
// a range that produced the same text".
|
|
275
|
+
function commitBlockMove(state, src, dest) {
|
|
276
|
+
const plan = planBlockMove(state.lines, state.blocks, src, dest);
|
|
277
|
+
if (!plan) {
|
|
278
|
+
return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
|
|
279
|
+
}
|
|
280
|
+
const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'));
|
|
281
|
+
r.newRange = plan.newRange;
|
|
282
|
+
return r;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── S4 Task 4: the pure half of a LIST ITEM's move (spec §4.5) ─────────
|
|
286
|
+
//
|
|
287
|
+
// A li's lines CANNOT be relocated the way planBlockMove() relocates every
|
|
288
|
+
// other block's — verbatim. Markers and ordinals are RUN-GLOBAL: '3. charlie'
|
|
289
|
+
// moved to the head of its run has to come back as '1. charlie' with
|
|
290
|
+
// everything behind it renumbered, and its indent prefix is a column count
|
|
291
|
+
// measured against the marker widths standing above it. So a li's move is
|
|
292
|
+
// expressed the way duplicateListItems() expresses a duplicate — as a
|
|
293
|
+
// REORDERED SPAN ARRAY handed to serializeBlocks(), committed over the run's
|
|
294
|
+
// own line range as ONE commitRangeEdit (§3.4: one gesture, one undo op).
|
|
295
|
+
// These two functions are the parts of that reasoning with no DOM in them.
|
|
296
|
+
|
|
297
|
+
// The span order after "the `count` members starting at `from` go
|
|
298
|
+
// immediately before the member at `insertAt`". `insertAt` names a slot in
|
|
299
|
+
// the array BEFORE the removal — it comes straight from the drop target's
|
|
300
|
+
// `before-block N` — so a DOWNWARD move is decremented by the WHOLE count
|
|
301
|
+
// and an upward one is not decremented at all: the same `insertAt` means two
|
|
302
|
+
// different slots depending on the direction, which is the whole reason this
|
|
303
|
+
// is a named function with its own test rather than a splice pair at the
|
|
304
|
+
// call site.
|
|
305
|
+
//
|
|
306
|
+
// S4 Task 6 widened it from one member to `count`, in place rather than
|
|
307
|
+
// beside: §4.5's 「grip 在選取集合內 → 整批搬」 is the SAME arithmetic and a
|
|
308
|
+
// second copy of an off-by-one is the 「不得另寫一條」 shape §3.6 has already
|
|
309
|
+
// ruled against twice. The single-item case is `count === 1` and its whole
|
|
310
|
+
// test table was migrated onto this signature unchanged.
|
|
311
|
+
//
|
|
312
|
+
// `null` means the order would be UNCHANGED, which is a byte no-op and not a
|
|
313
|
+
// failure: every `insertAt` from `from` to `from + count` inclusive is the
|
|
314
|
+
// SET'S OWN FOOTPRINT — `from` is "released on my own top edge",
|
|
315
|
+
// `from + count` is "released on the seam immediately below me", and the
|
|
316
|
+
// slots in between name a position INSIDE the set, which is not a reorder of
|
|
317
|
+
// the span but a shuffle of members that are travelling together. Visually
|
|
318
|
+
// they are all the same place, and they are the case the drag's own
|
|
319
|
+
// release-where-you-started gesture depends on. An out-of-range index, an
|
|
320
|
+
// empty set and a set that runs off the end answer `null` too; there is no
|
|
321
|
+
// order any of them could name.
|
|
322
|
+
function reorderSpanRange(length, from, count, insertAt) {
|
|
323
|
+
if (!(length > 0) || !(count > 0)) return null;
|
|
324
|
+
if (!(from >= 0) || from + count > length) return null;
|
|
325
|
+
if (!(insertAt >= 0) || insertAt > length) return null;
|
|
326
|
+
if (insertAt >= from && insertAt <= from + count) return null;
|
|
327
|
+
const rest = [];
|
|
328
|
+
const moved = [];
|
|
329
|
+
for (let i = 0; i < length; i++) {
|
|
330
|
+
if (i >= from && i < from + count) moved.push(i); else rest.push(i);
|
|
331
|
+
}
|
|
332
|
+
const at = insertAt > from ? insertAt - count : insertAt;
|
|
333
|
+
return rest.slice(0, at).concat(moved, rest.slice(at));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── S4 Task 6: the LINE RANGE a batch move relocates (spec §3.3, §4.5) ──
|
|
337
|
+
//
|
|
338
|
+
// §3.3's operand set is CONTIGUOUS in block order — resolveGutterOperands()
|
|
339
|
+
// refuses a gap before anything reaches here — so its members' lines plus
|
|
340
|
+
// the separators standing between them are ONE contiguous run, which is
|
|
341
|
+
// exactly what planBlockMove() relocates verbatim. That is the whole reason
|
|
342
|
+
// a batch move is the SAME single commitRangeEdit a one-block move is, and
|
|
343
|
+
// therefore the same single Ctrl+Z (§3.4).
|
|
344
|
+
//
|
|
345
|
+
// Named rather than written as an object literal at the call site because
|
|
346
|
+
// the set reaches the bytes through this one expression and nothing else: an
|
|
347
|
+
// endLine taken from any member but the LAST leaves the rest of the set
|
|
348
|
+
// standing where it was while its head travels, and the blank-line rules
|
|
349
|
+
// then re-emit separators around both halves — a document the user never
|
|
350
|
+
// asked for, from a one-token typo. A pure function is a mutation a test can
|
|
351
|
+
// kill in a millisecond instead of a browser round trip.
|
|
352
|
+
function spanMoveRange(recs) {
|
|
353
|
+
if (!recs || !recs.length) return null;
|
|
354
|
+
return { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// §4.5's INDENT SEAM, asked of the span AS IT WOULD BE COMMITTED.
|
|
358
|
+
//
|
|
359
|
+
// Markdown indent is only ever RELATIVE (indent-clamp.js's own opening
|
|
360
|
+
// sentence): an item's depth exists solely because a shallower item stands
|
|
361
|
+
// above it. serializeBlocks() rebuilds its marker-width stack as it walks the
|
|
362
|
+
// array, from empty — so a span whose FIRST block claims `data-indent="1"`
|
|
363
|
+
// emits that block at column 0, and the DOM and the file then disagree about
|
|
364
|
+
// the nesting. A move creates exactly that by lifting an item out from above
|
|
365
|
+
// its own child.
|
|
366
|
+
//
|
|
367
|
+
// The bound is deliberately the one indent-clamp.js's `boundAt()` computes
|
|
368
|
+
// — the previous li's indent + 1, and 0 when there is no previous li or the
|
|
369
|
+
// previous block is not one — so the predicate and the clamp cannot drift.
|
|
370
|
+
//
|
|
371
|
+
// S4 Task 7 KEPT it and moved it to AFTER the clamp. Asked of the raw
|
|
372
|
+
// reordered span it refused 1425 of the 4067 legal drops; the clamp now
|
|
373
|
+
// answers 1411 of those, and what this still refuses is the 14 no
|
|
374
|
+
// removal-clamp can reach — a batch whose last member is shallower than the
|
|
375
|
+
// block it lands before, i.e. the INSERTION half of the move. See
|
|
376
|
+
// performListItemDrop() for the single site and BLOCK_MOVE_ORPHAN_MESSAGE
|
|
377
|
+
// for the family and the sweep behind those numbers.
|
|
378
|
+
function spanIndentsAreAnchored(items) {
|
|
379
|
+
let prevLi = null;
|
|
380
|
+
for (let i = 0; i < (items || []).length; i++) {
|
|
381
|
+
const it = items[i] || {};
|
|
382
|
+
if (it.type !== 'li') { prevLi = null; continue; }
|
|
383
|
+
const indent = typeof it.indent === 'number' ? it.indent : 0;
|
|
384
|
+
if (indent > (prevLi === null ? 0 : prevLi + 1)) return false;
|
|
385
|
+
prevLi = indent;
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── S4 Task 5: the CROSS-BOUNDARY seam predicate (spec §4.5) ──────────
|
|
391
|
+
//
|
|
392
|
+
// §4.5's ruling, made by the user on 2026-09-01 and implemented rather than
|
|
393
|
+
// re-opened: a cross-boundary move is REFUSED with a banner in 3.0.0 and the
|
|
394
|
+
// capability deferred to 3.1.0, whose first step is amending the spec with
|
|
395
|
+
// the blank-line rule for a run/non-run seam. This function is the whole of
|
|
396
|
+
// "which moves cross a boundary" for a block that is NOT a list item; a li
|
|
397
|
+
// source has its own, narrower question (its destination must be a slot in
|
|
398
|
+
// its own §3.8 run) and answers it in performListItemDrop().
|
|
399
|
+
//
|
|
400
|
+
// A move touches TWO seams and they need DIFFERENT rules. That asymmetry is
|
|
401
|
+
// the task's finding, and every row below is a marked.lexer measurement:
|
|
402
|
+
//
|
|
403
|
+
// DESTINATION — where the block LANDS. An insertion can only ever SPLIT;
|
|
404
|
+
// it has no way to merge two lists. So the only thing it can break is a
|
|
405
|
+
// run it lands INSIDE, and the question is exactly "are these two
|
|
406
|
+
// neighbours members of ONE run":
|
|
407
|
+
// '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para` spliced
|
|
408
|
+
// between the items -> list(1) | paragraph | list(1): one two-item run
|
|
409
|
+
// became two one-item runs, and the blanks rule 3 emits either side
|
|
410
|
+
// are §4.3's looseness trap. REFUSED.
|
|
411
|
+
// '# Doc\n\n- a\n\n1. b\n\ntail\n' with `para` spliced at the
|
|
412
|
+
// same-looking seam -> heading | ul(1,tight) | paragraph |
|
|
413
|
+
// ol(1,tight) | paragraph. Nothing merged, nothing loosened, because
|
|
414
|
+
// these were already two lists. ACCEPTED — this is Task 3's
|
|
415
|
+
// deliberate over-refusal, and narrowing it is what Task 5 owed.
|
|
416
|
+
//
|
|
417
|
+
// SOURCE — where the block LEAVES. A removal MERGES, and it CANNOT be
|
|
418
|
+
// narrowed with anything the block model carries. Every one of these
|
|
419
|
+
// leaves ONE list with `loose === true`, i.e. every item renders as a <p>,
|
|
420
|
+
// serializeBlocks() reports 'P' for each, and the whole run degrades
|
|
421
|
+
// read-only with no banner:
|
|
422
|
+
// '- a' | para | '- b' -> '- a\n\n- b\n'
|
|
423
|
+
// '1. a' | para | '2. b' -> '1. a\n\n2. b\n'
|
|
424
|
+
// '- [ ] a' | para | '- [x] b' -> '- [ ] a\n\n- [x] b\n'
|
|
425
|
+
// '- a' | para | ' 1. a1' -> '- a\n\n 1. a1\n'
|
|
426
|
+
// The last one is why the obvious narrowings are all wrong. blockmap
|
|
427
|
+
// reports those two items as (indent 0, listType 'ul') and (indent 0,
|
|
428
|
+
// listType 'ol') — DIFFERENT runs AND different list types, so every fact
|
|
429
|
+
// the model exposes says "two lists, safe", while the file says loose.
|
|
430
|
+
// What decides it is the two-space prefix on a raw line, which no block
|
|
431
|
+
// attribute carries and which the client cannot lex (render is a server
|
|
432
|
+
// round trip; there is no lexer in the browser). The mirror over-refusal
|
|
433
|
+
// is '- a' | para | '* b' -> '- a\n\n* b\n', measurably TWO tight
|
|
434
|
+
// lists and still refused, because the discriminator there is the bullet
|
|
435
|
+
// CHARACTER — another raw byte the model does not carry. Recorded in §4.5
|
|
436
|
+
// as a deliberate over-refusal for 3.1.0, not as a correctness claim.
|
|
437
|
+
//
|
|
438
|
+
// `srcSeam` / `destSeam` are `{prev, next}`; each side is null when there is
|
|
439
|
+
// no such block or it is not a li, and otherwise `{runKey}` — the
|
|
440
|
+
// `data-block-id` of the FIRST member of listRunOf(), which is §3.4 rule 2's
|
|
441
|
+
// scope, so a nested child and its parent answer the SAME key. A null
|
|
442
|
+
// runKey (a run the DOM could not answer) counts as "same": an unknown seam
|
|
443
|
+
// refuses rather than guesses, because the guess that goes wrong corrupts a
|
|
444
|
+
// list and corrupting a list is not undoable.
|
|
445
|
+
//
|
|
446
|
+
// Returns 'source' | 'destination' | null. When both seams object the
|
|
447
|
+
// SOURCE one is reported: it is the objection that holds wherever the user
|
|
448
|
+
// aims next, so it is the one worth telling them about.
|
|
449
|
+
//
|
|
450
|
+
// Pure, and exported, because the enumeration it encodes is a table of
|
|
451
|
+
// measurements — a gesture-level test can only reach a handful of its rows.
|
|
452
|
+
function blockMoveSeamRefusal(srcSeam, destSeam) {
|
|
453
|
+
const s = srcSeam || {};
|
|
454
|
+
const d = destSeam || {};
|
|
455
|
+
if (s.prev && s.next) return 'source';
|
|
456
|
+
if (d.prev && d.next &&
|
|
457
|
+
(d.prev.runKey === null || d.next.runKey === null ||
|
|
458
|
+
d.prev.runKey === d.next.runKey)) return 'destination';
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
177
462
|
// ── T8 review MEDIUM-1: the one correct way to undo a failed render ─────
|
|
178
463
|
// Six sites in this file share the shape "commit optimistically, re-render,
|
|
179
464
|
// and if the render failed put `lines` back". The inlined version of that
|
|
@@ -222,7 +507,7 @@
|
|
|
222
507
|
}
|
|
223
508
|
|
|
224
509
|
if (typeof module === 'object' && module.exports) {
|
|
225
|
-
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
510
|
+
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
226
511
|
return; // node: pure core only
|
|
227
512
|
}
|
|
228
513
|
|
|
@@ -635,6 +920,11 @@
|
|
|
635
920
|
// ago was just destroyed by the innerHTML replacement above.
|
|
636
921
|
hideTableEdgeMenu();
|
|
637
922
|
cancelTeDrag();
|
|
923
|
+
// S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
|
|
924
|
+
// the innerHTML replacement above just detached every block the gesture's
|
|
925
|
+
// ordinal refers to. Nothing was mutated by the drag, so there is nothing
|
|
926
|
+
// to revert; the state simply must not survive the render.
|
|
927
|
+
cancelBlockDrag();
|
|
638
928
|
// Final-review Finding 5b (Important): same belt-and-braces reasoning
|
|
639
929
|
// again for the ⠿ gutter menu — `toggleGutterMenu()` appends the ONE
|
|
640
930
|
// shared `gutterMenu` node as a CHILD of whichever block it's open for
|
|
@@ -1357,29 +1647,56 @@
|
|
|
1357
1647
|
// computes it with spanMinIndent(). A member that is not in `spanEls` aborts
|
|
1358
1648
|
// the whole clamp rather than clamping a subset — a partial op set makes rule
|
|
1359
1649
|
// 2's scope start in the wrong place, which is a silent wrong answer.
|
|
1360
|
-
|
|
1361
|
-
|
|
1650
|
+
// S4 Task 7 split this in two WITHOUT changing what any existing caller does:
|
|
1651
|
+
// computeIndentClamp() answers the clamp, applyIndentClamp() writes it, and
|
|
1652
|
+
// the four callers that existed before this task still call the latter and
|
|
1653
|
+
// still see exactly the answer they saw. The reason for the split is the ⠿
|
|
1654
|
+
// DROP, and it is not a stylistic one: every other caller clamps a span it
|
|
1655
|
+
// has already decided to commit, while the drop still has ONE refusal left
|
|
1656
|
+
// after the clamp (see performListItemDrop()). Writing `data-indent` on a
|
|
1657
|
+
// gesture that then refuses leaves a half-clamped DOM behind — the same
|
|
1658
|
+
// "the screen and the model disagree" failure the clamp exists to prevent,
|
|
1659
|
+
// produced by the clamp itself. So the drop asks first and writes only once
|
|
1660
|
+
// it knows it is committing.
|
|
1661
|
+
//
|
|
1662
|
+
// `null` is "no answer at all": no clamp module, an empty span, or an
|
|
1663
|
+
// operated element that is not IN the span. That last one abandons the whole
|
|
1664
|
+
// clamp rather than clamping a subset — a partial op set makes rule 2's scope
|
|
1665
|
+
// start in the wrong place, which is a silent wrong answer. Every caller
|
|
1666
|
+
// treats `null` as "leave every indent alone", which is what the pre-split
|
|
1667
|
+
// function did by returning early.
|
|
1668
|
+
function computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1669
|
+
if (!indentClamp || !spanEls || !spanEls.length) return null;
|
|
1362
1670
|
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1363
1671
|
const opIndex = [];
|
|
1364
1672
|
for (let k = 0; k < opEls.length; k++) {
|
|
1365
1673
|
const at = spanEls.indexOf(opEls[k]);
|
|
1366
|
-
if (at < 0) return;
|
|
1674
|
+
if (at < 0) return null;
|
|
1367
1675
|
opIndex.push(at);
|
|
1368
1676
|
}
|
|
1369
|
-
if (!opIndex.length) return;
|
|
1677
|
+
if (!opIndex.length) return null;
|
|
1370
1678
|
const model = spanEls.map((el, i) => ({
|
|
1371
1679
|
id: i, // index-as-id: the span IS the universe here
|
|
1372
1680
|
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
1373
1681
|
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
1374
1682
|
}));
|
|
1375
|
-
indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {})
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1683
|
+
return indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {})
|
|
1684
|
+
.map((r) => ({ el: spanEls[r.blockId], indent: r.indent }))
|
|
1685
|
+
.filter((r) => !!r.el);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function writeIndentClamp(answer) {
|
|
1689
|
+
(answer || []).forEach((r) => {
|
|
1690
|
+
if ((Number(r.el.getAttribute('data-indent')) || 0) !== r.indent) {
|
|
1691
|
+
setBlockIndent(r.el, r.indent);
|
|
1379
1692
|
}
|
|
1380
1693
|
});
|
|
1381
1694
|
}
|
|
1382
1695
|
|
|
1696
|
+
function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1697
|
+
writeIndentClamp(computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts));
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1383
1700
|
function placeCaretAtEnd(el) {
|
|
1384
1701
|
const range = document.createRange();
|
|
1385
1702
|
range.selectNodeContents(el);
|
|
@@ -1692,6 +2009,30 @@
|
|
|
1692
2009
|
'<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
|
|
1693
2010
|
// MD 原始碼 — angle brackets: source, not prose.
|
|
1694
2011
|
code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
|
|
2012
|
+
// ── v3.0.1: one icon per 轉換成 target ────────────────────────────────
|
|
2013
|
+
// Keys are CONVERT_TARGETS[].id verbatim; openConvertSubmenu() looks them
|
|
2014
|
+
// up by that id, so a rename on either side must be made on both.
|
|
2015
|
+
// 文字 — a paragraph pilcrow, drawn rather than typed so it inherits the
|
|
2016
|
+
// same stroke weight as every sibling icon.
|
|
2017
|
+
text: '<path d="M9 2.5H6.2a2.7 2.7 0 0 0 0 5.4H9"/><path d="M9 2.5v11"/><path d="M11.8 2.5v11"/>',
|
|
2018
|
+
// 標題 1–6 — an "H" plus the level, as two strokes and a numeral bar set.
|
|
2019
|
+
h1: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11.5 6.5l1.5-1v7.5"/>',
|
|
2020
|
+
h2: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 6.6a1.6 1.6 0 0 1 2.7 1.1c0 1.4-2.7 2.6-2.7 5.3h3"/>',
|
|
2021
|
+
h3: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 5.8h2.7l-1.6 2.6a1.8 1.8 0 1 1-1.3 3"/>',
|
|
2022
|
+
h4: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13 5.8v7.2"/><path d="M13 10.7h-2.6l2.2-4.9"/>',
|
|
2023
|
+
h5: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.5 5.8h-2.6v2.8a1.9 1.9 0 1 1-.5 3.6"/>',
|
|
2024
|
+
h6: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.4 6.2a1.9 1.9 0 0 0-3 1.5v3.2a1.8 1.8 0 1 0 3.1-1.3 1.8 1.8 0 0 0-3.1 1.3"/>',
|
|
2025
|
+
// 項目符號列表 — three dots with three rules.
|
|
2026
|
+
ul: '<circle cx="3.2" cy="4.2" r="1"/><circle cx="3.2" cy="8" r="1"/><circle cx="3.2" cy="11.8" r="1"/>' +
|
|
2027
|
+
'<path d="M6.4 4.2h7.4"/><path d="M6.4 8h7.4"/><path d="M6.4 11.8h7.4"/>',
|
|
2028
|
+
// 編號列表 — the same three rules led by 1/2/3 strokes.
|
|
2029
|
+
ol: '<path d="M2.2 3.4l1-.6v2.8"/><path d="M2 8.2a1 1 0 0 1 1.7.7c0 .9-1.7 1.6-1.7 2.4h1.9"/>' +
|
|
2030
|
+
'<path d="M2.1 11.4h1.6l-1 1.1a1 1 0 1 1-.7 1.2"/>' +
|
|
2031
|
+
'<path d="M6.4 4.2h7.4"/><path d="M6.4 8.6h7.4"/><path d="M6.4 12.9h7.4"/>',
|
|
2032
|
+
// 待辦清單 — a checked box.
|
|
2033
|
+
task: '<rect x="2.2" y="2.6" width="10.8" height="10.8" rx="2.4"/><path d="M5.2 8.2l2.1 2.1 3.8-4"/>',
|
|
2034
|
+
// 引用 — a quote bar plus two shortened rules.
|
|
2035
|
+
quote: '<path d="M3 3.4v9.2"/><path d="M6.4 5.4h7.2"/><path d="M6.4 8h7.2"/><path d="M6.4 10.6h4.6"/>',
|
|
1695
2036
|
};
|
|
1696
2037
|
function menuIconMarkup(name) {
|
|
1697
2038
|
return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
|
|
@@ -1874,7 +2215,13 @@
|
|
|
1874
2215
|
const b = document.createElement('button');
|
|
1875
2216
|
b.type = 'button';
|
|
1876
2217
|
b.className = 'ed-handle-menu-btn';
|
|
1877
|
-
|
|
2218
|
+
// v3.0.1: the submenu carries icons too. Without them its labels start
|
|
2219
|
+
// at the button's own padding while the parent menu's start past a 16px
|
|
2220
|
+
// icon, so the two levels' text does not share a vertical line — the
|
|
2221
|
+
// exact defect the user reported. `.ed-handle-menu-btn` is already a
|
|
2222
|
+
// flex row with a gap, so an icon plus a text node is all it takes.
|
|
2223
|
+
b.innerHTML = menuIconMarkup(t.id);
|
|
2224
|
+
b.appendChild(document.createTextNode(t.label));
|
|
1878
2225
|
b.setAttribute('aria-label', 'Convert to ' + t.id);
|
|
1879
2226
|
b.addEventListener('click', (e) => {
|
|
1880
2227
|
e.stopPropagation();
|
|
@@ -2558,6 +2905,48 @@
|
|
|
2558
2905
|
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
2559
2906
|
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
2560
2907
|
const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
|
|
2908
|
+
// S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
|
|
2909
|
+
//
|
|
2910
|
+
// Task 3 raised all of these with a single provisional constant
|
|
2911
|
+
// (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
|
|
2912
|
+
// owned the final wording. They are three different problems with three
|
|
2913
|
+
// OPPOSITE remedies, so one sentence for all of them sends two thirds of
|
|
2914
|
+
// the users to fix the wrong end of their own gesture:
|
|
2915
|
+
// * the SOURCE seam — the block cannot leave where it is, because the two
|
|
2916
|
+
// lists it stands between would join into one loose run. No destination
|
|
2917
|
+
// helps; the remedy is not to move it (or to move something else).
|
|
2918
|
+
// * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
|
|
2919
|
+
// is to aim somewhere else, which is the exact opposite advice.
|
|
2920
|
+
// * OUT OF RUN — a list item may only move among its own §3.8 siblings.
|
|
2921
|
+
// The remedy is to stay inside its own list.
|
|
2922
|
+
// Naming follows the BATCH_*_MESSAGE convention above. None of the three is
|
|
2923
|
+
// a substring of another, of §4.3's own gate message, or of
|
|
2924
|
+
// BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
|
|
2925
|
+
// refusal fired rather than that some banner mentioning 清單 appeared.
|
|
2926
|
+
const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
|
|
2927
|
+
const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
|
|
2928
|
+
const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
|
|
2929
|
+
// S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
|
|
2930
|
+
// of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
|
|
2931
|
+
// removal half of the move through clampIndents()'s `{ removed: true }`),
|
|
2932
|
+
// which answers every SOURCE-side orphan. What no removal-clamp can reach is
|
|
2933
|
+
// the INSERTION half: a BATCH whose last member is SHALLOWER than the block
|
|
2934
|
+
// it lands before leaves that block with no parent chain above it, at the
|
|
2935
|
+
// NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
|
|
2936
|
+
// indent array of length 2..6 and depth 0..3, every operand-set size and
|
|
2937
|
+
// every destination the run gate admits: 4067 drops, 1425 refused by Task
|
|
2938
|
+
// 4's predicate, 14 still unanchored after the clamp — every one of them a
|
|
2939
|
+
// multi-block set breaking at the block immediately after the landed set.
|
|
2940
|
+
// (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
|
|
2941
|
+
//
|
|
2942
|
+
// RE-WORDED with the narrowing, and the wording is the point: Task 4's
|
|
2943
|
+
// 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
|
|
2944
|
+
// was true while the capability was missing and is a lie now that it is not.
|
|
2945
|
+
// What is left is a DESTINATION objection with the same remedy
|
|
2946
|
+
// BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
|
|
2947
|
+
// end of the gesture is at fault. Still not a substring of any other refusal
|
|
2948
|
+
// in this file, which is what lets a test assert WHICH one fired.
|
|
2949
|
+
const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
|
|
2561
2950
|
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
2562
2951
|
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
2563
2952
|
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
@@ -3641,6 +4030,10 @@
|
|
|
3641
4030
|
hideTableGrips();
|
|
3642
4031
|
hideTableEdgeMenu();
|
|
3643
4032
|
cancelTeDrag();
|
|
4033
|
+
// S4 Task 2: and an in-flight ⠿ block drag, for the same reason — this
|
|
4034
|
+
// resolution is about to commit and re-render whichever block it holds an
|
|
4035
|
+
// ordinal into.
|
|
4036
|
+
cancelBlockDrag();
|
|
3644
4037
|
if (!burst) return true;
|
|
3645
4038
|
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
3646
4039
|
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
@@ -3823,7 +4216,16 @@
|
|
|
3823
4216
|
// S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
|
|
3824
4217
|
// emitted type:'list' blocks since Phase 4, so no startBurst() call could
|
|
3825
4218
|
// produce blockType === 'list' and the branch was already dead code.
|
|
3826
|
-
|
|
4219
|
+
// v3.0.1: hand the serializer the table's CURRENT source lines so it can
|
|
4220
|
+
// reuse the author's own column widths instead of flattening the whole
|
|
4221
|
+
// table to minimal form over a one-cell edit. extractBlockSource() is the
|
|
4222
|
+
// same helper the raw editor prefills from, so "what the file says" has
|
|
4223
|
+
// exactly one definition.
|
|
4224
|
+
const tableBlockRec = burst.blockType === 'table'
|
|
4225
|
+
? blocks.find((b) => b.id === burst.blockId) : null;
|
|
4226
|
+
const result = burst.blockType === 'table'
|
|
4227
|
+
? tableMd.serializeTable(burst.editEl,
|
|
4228
|
+
tableBlockRec ? extractBlockSource(lines, tableBlockRec) : null)
|
|
3827
4229
|
: inlineMd.serializeInline(burst.editEl);
|
|
3828
4230
|
if (result.unsupported.length > 0) {
|
|
3829
4231
|
// Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
|
|
@@ -5105,16 +5507,29 @@
|
|
|
5105
5507
|
function indentListItem(blockEl) {
|
|
5106
5508
|
const self = liAttrs(blockEl);
|
|
5107
5509
|
if (!self) return false;
|
|
5108
|
-
// Rule (d): the first item of a LIST has nothing above it to nest under.
|
|
5109
|
-
// Without this the §3.4 clamp would happily read the previous list's last
|
|
5110
|
-
// item as "the previous block" and indent this one underneath it, merging
|
|
5111
|
-
// two lists the user never asked to join. Pre-S1 this fell out of
|
|
5112
|
-
// `previousElementSibling` being null inside the item's own <ul>.
|
|
5113
|
-
if (self.listStart) return false;
|
|
5114
5510
|
const all = allBlockEls();
|
|
5115
5511
|
const i = all.indexOf(blockEl);
|
|
5116
5512
|
if (i < 0) return false;
|
|
5117
5513
|
const prev = liAttrs(all[i - 1]);
|
|
5514
|
+
// Rule (d), narrowed (v3.0.1). The old rule was `if (self.listStart)
|
|
5515
|
+
// return false` — the first item of a LIST has nothing above it to nest
|
|
5516
|
+
// under. That over-refused: a bullet outdented to indent 0 directly
|
|
5517
|
+
// BELOW an ordered item is a listStart (a ul following an ol) whose
|
|
5518
|
+
// previous block is a perfectly good parent, and the user could never
|
|
5519
|
+
// indent it back. The half that still has to be refused is the one the
|
|
5520
|
+
// old rule was really aimed at: two list tokens the user kept apart with
|
|
5521
|
+
// a BLANK LINE. Indenting the second one makes listRunOf() span both,
|
|
5522
|
+
// and re-serializing that span swallows the blank line — a rewrite of
|
|
5523
|
+
// lines nobody touched, which is the one thing this editor must never do.
|
|
5524
|
+
// Contiguity is a fact about the FILE, so it is read off `blocks`, not
|
|
5525
|
+
// guessed from the DOM.
|
|
5526
|
+
if (self.listStart) {
|
|
5527
|
+
if (!prev) return false;
|
|
5528
|
+
const prevRec = blockRecOf(all[i - 1]);
|
|
5529
|
+
const selfRec = blockRecOf(blockEl);
|
|
5530
|
+
if (!prevRec || !selfRec) return false;
|
|
5531
|
+
if (prevRec.endLine + 1 !== selfRec.startLine) return false;
|
|
5532
|
+
}
|
|
5118
5533
|
const max = prev ? prev.indent + 1 : 0;
|
|
5119
5534
|
const next = Math.min(self.indent + 1, max);
|
|
5120
5535
|
if (next === self.indent) return false;
|
|
@@ -5191,22 +5606,39 @@
|
|
|
5191
5606
|
|
|
5192
5607
|
// How far ONE member may move, as a magnitude (0 or 1), never a signed
|
|
5193
5608
|
// delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
|
|
5194
|
-
// indentListItem()'s own single-item rule
|
|
5195
|
-
//
|
|
5196
|
-
//
|
|
5197
|
-
//
|
|
5198
|
-
//
|
|
5199
|
-
//
|
|
5200
|
-
//
|
|
5609
|
+
// indentListItem()'s own single-item rule, v3.0.1's narrowed list-start
|
|
5610
|
+
// clause included: a listStart member still refuses UNLESS the block
|
|
5611
|
+
// immediately before it (by file line, via blockRecOf — same contiguity
|
|
5612
|
+
// check, same code shape as indentListItem()'s, kept in that shape
|
|
5613
|
+
// deliberately so the two stay in visible agreement) is a contiguous list
|
|
5614
|
+
// item, in which case it has real head-room like any other item. Before
|
|
5615
|
+
// v3.0.1 this was unconditional (`if (self.listStart) return 0;`) —
|
|
5616
|
+
// batch Tab on a listStart block was refused outright, matching
|
|
5617
|
+
// indentListItem()'s then-unconditional refusal. Narrowing ONE of the two
|
|
5618
|
+
// without the other silently reintroduces the single-item defect on the
|
|
5619
|
+
// batch axis: a listStart item directly below a contiguous ordered item,
|
|
5620
|
+
// selected as part of a batch, would stay refused even though the very
|
|
5621
|
+
// same block indents fine on its own. The floor is outdentListItem()'s
|
|
5622
|
+
// (indent 0 cannot rise), untouched by this narrowing. Both read PRE-move
|
|
5623
|
+
// indents, which is §3.4's global convention ("the operated block's
|
|
5624
|
+
// indent" always means the value before the operation), and it is what
|
|
5625
|
+
// makes the minimum below well-defined: every member is measured against
|
|
5626
|
+
// the document as it stands, not against members already moved.
|
|
5201
5627
|
function memberIndentHeadroom(el, dir) {
|
|
5202
5628
|
const self = liAttrs(el);
|
|
5203
5629
|
if (!self) return 0;
|
|
5204
5630
|
if (dir < 0) return self.indent > 0 ? 1 : 0;
|
|
5205
|
-
if (self.listStart) return 0;
|
|
5206
5631
|
const all = allBlockEls();
|
|
5207
5632
|
const i = all.indexOf(el);
|
|
5208
5633
|
if (i < 0) return 0;
|
|
5209
5634
|
const prev = liAttrs(all[i - 1]);
|
|
5635
|
+
if (self.listStart) {
|
|
5636
|
+
if (!prev) return 0;
|
|
5637
|
+
const prevRec = blockRecOf(all[i - 1]);
|
|
5638
|
+
const selfRec = blockRecOf(el);
|
|
5639
|
+
if (!prevRec || !selfRec) return 0;
|
|
5640
|
+
if (prevRec.endLine + 1 !== selfRec.startLine) return 0;
|
|
5641
|
+
}
|
|
5210
5642
|
const max = prev ? prev.indent + 1 : 0;
|
|
5211
5643
|
return Math.max(0, Math.min(1, max - self.indent));
|
|
5212
5644
|
}
|
|
@@ -5260,7 +5692,30 @@
|
|
|
5260
5692
|
// segment delta for the blocks below instead of N independent clamps, and
|
|
5261
5693
|
// its rule 1 then walks the operated blocks in document order so each is
|
|
5262
5694
|
// measured against the member above it that the same pass just settled.
|
|
5263
|
-
|
|
5695
|
+
//
|
|
5696
|
+
// v3.0.1 (review round 1 follow-up): `run` above is batchRunOf()'s
|
|
5697
|
+
// PRE-mutation snapshot — the exact same staleness handleLiKeydown()'s
|
|
5698
|
+
// single-item Tab branch had before its own v3.0.1 fix (see that
|
|
5699
|
+
// call's comment). For a single-member batch on a listStart item whose
|
|
5700
|
+
// ceiling memberIndentHeadroom() has just narrowed (a bullet directly
|
|
5701
|
+
// below a contiguous ordered item), the pre-mutation run is `[liEls[0]]`
|
|
5702
|
+
// alone — batchRunOf() never walked backward past the list-start
|
|
5703
|
+
// boundary, because before this fix nothing could ever cross it. With
|
|
5704
|
+
// no anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
|
|
5705
|
+
// bound the indent the forEach above just wrote against, and clamps it
|
|
5706
|
+
// straight back to 0 — silently undoing the write, MEASURED directly:
|
|
5707
|
+
// narrowing memberIndentHeadroom() alone left the covering test RED.
|
|
5708
|
+
// A FRESH listRunOf(liEls[0]) call here — taken after the write above —
|
|
5709
|
+
// walks back from the NEW indent and finds the real parent, exactly
|
|
5710
|
+
// like the post-mutation `liveRun = listRunOf(liEls[0])` a few lines
|
|
5711
|
+
// below (the COMMIT span) already does; using the same fresh call for
|
|
5712
|
+
// the clamp keeps the two consistent, same reasoning as
|
|
5713
|
+
// handleLiKeydown()'s Tab branch. Provably identical to the old stale
|
|
5714
|
+
// `run` for every case where no member was ever a list-start
|
|
5715
|
+
// (listRunOf() slices by INDEX RANGE, and neither the range's
|
|
5716
|
+
// start/end index nor the elements between them shift when only
|
|
5717
|
+
// already-run-member indents change).
|
|
5718
|
+
applyIndentClamp(listRunOf(liEls[0]), liEls, oldIndent);
|
|
5264
5719
|
});
|
|
5265
5720
|
// Re-derived AFTER the mutation, like the caret Tab's: an indent change can
|
|
5266
5721
|
// move a block between runs.
|
|
@@ -5491,7 +5946,26 @@
|
|
|
5491
5946
|
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
5492
5947
|
const changed = mutateListRun(() => {
|
|
5493
5948
|
if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
|
|
5494
|
-
|
|
5949
|
+
// v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
|
|
5950
|
+
// still had its OLD indent. That is fine for outdentListItem() — the
|
|
5951
|
+
// clamp's model rereads every element's CURRENT data-indent off the
|
|
5952
|
+
// DOM, so it self-corrects regardless of which elements the snapshot
|
|
5953
|
+
// names — but indentListItem() can now move a rule-(d) list-start
|
|
5954
|
+
// item (a bullet directly below an ordered item, say) whose own
|
|
5955
|
+
// pre-mutation run is `[li]` alone: nothing shallower ever preceded
|
|
5956
|
+
// it, because runBlocksOf() stops at a list-start by design. With no
|
|
5957
|
+
// anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
|
|
5958
|
+
// bound the indent this call just wrote against, and clamps it
|
|
5959
|
+
// straight back to 0 — silently undoing the write. A FRESH
|
|
5960
|
+
// listRunOf(li) call here — taken after the write above — walks back
|
|
5961
|
+
// from li's NEW indent and finds the real parent, exactly like the
|
|
5962
|
+
// post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
|
|
5963
|
+
// span) already does; using the same fresh call for the clamp keeps
|
|
5964
|
+
// the two consistent. Provably identical to the old stale `run` for
|
|
5965
|
+
// every case where li was never a list-start (listRunOf() slices by
|
|
5966
|
+
// INDEX RANGE, and neither the range's start/end index nor the
|
|
5967
|
+
// elements between them shift when only li's own indent changes).
|
|
5968
|
+
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
5495
5969
|
return true;
|
|
5496
5970
|
});
|
|
5497
5971
|
if (!changed) return true;
|
|
@@ -6063,8 +6537,12 @@
|
|
|
6063
6537
|
function tableIdentityOf(tableEl) {
|
|
6064
6538
|
const headerRow = tableEl ? headerRowOf(tableEl) : null;
|
|
6065
6539
|
if (!headerRow) return null;
|
|
6066
|
-
|
|
6067
|
-
|
|
6540
|
+
// The two delimiters are written as ESCAPES, not as literal control
|
|
6541
|
+
// bytes. They used to be literal, which made grep classify this whole
|
|
6542
|
+
// 9643-line file as binary and SILENTLY TRUNCATE its output — a real
|
|
6543
|
+
// investigation was misled by it. The runtime value is identical.
|
|
6544
|
+
return allRowsOf(tableEl).length + '\x01' +
|
|
6545
|
+
Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('\x00');
|
|
6068
6546
|
}
|
|
6069
6547
|
async function ensureTableBurstOpen(tableEl) {
|
|
6070
6548
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
|
|
@@ -6229,8 +6707,9 @@
|
|
|
6229
6707
|
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
6230
6708
|
// user can actually see and aim for. Both grips are overlay elements
|
|
6231
6709
|
// `position: fixed`-appended to document.body — never DOM CHILDREN of a
|
|
6232
|
-
// contenteditable cell, even though the row grip
|
|
6233
|
-
//
|
|
6710
|
+
// contenteditable cell, even though the row grip visually overlaps one
|
|
6711
|
+
// (since §4.2 conflict 2 landed in S4 T1 it sits ENTIRELY inside the first
|
|
6712
|
+
// column, within that cell's own widened left padding; see the geometry
|
|
6234
6713
|
// note in the "Notion-style row/column grip handles" section below). So —
|
|
6235
6714
|
// unlike the old zones, which
|
|
6236
6715
|
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
@@ -6543,20 +7022,59 @@
|
|
|
6543
7022
|
// listener (wired near the bottom of this file) that already drives
|
|
6544
7023
|
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
6545
7024
|
// contract this reuses.
|
|
6546
|
-
//
|
|
6547
|
-
//
|
|
6548
|
-
//
|
|
6549
|
-
//
|
|
6550
|
-
// grip:
|
|
6551
|
-
//
|
|
6552
|
-
//
|
|
6553
|
-
//
|
|
6554
|
-
//
|
|
6555
|
-
// the
|
|
6556
|
-
//
|
|
6557
|
-
//
|
|
6558
|
-
//
|
|
6559
|
-
//
|
|
7025
|
+
// Geometry, per axis (v3.0.1: they are mirror images again):
|
|
7026
|
+
// COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
|
|
7027
|
+
// its hit rect straddles that border by half its own height on each
|
|
7028
|
+
// side (P0-a).
|
|
7029
|
+
// ROW grip: its CENTRELINE coincides with the table's LEFT border, so
|
|
7030
|
+
// its hit rect straddles that border by half its own width on each
|
|
7031
|
+
// side — same shape as the column grip, restored in v3.0.1 (spec §4.2
|
|
7032
|
+
// hit-test conflict 2).
|
|
7033
|
+
//
|
|
7034
|
+
// History: why the row axis briefly broke the symmetry, and how that was
|
|
7035
|
+
// undone. The block's own gutter ⠿ hangs outside the content box, and a
|
|
7036
|
+
// table block's own table starts at that same left edge, so a row grip
|
|
7037
|
+
// centred on the table's left border occupied [blockLeft-10, blockLeft+10]
|
|
7038
|
+
// and overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
|
|
7039
|
+
// positioned overlays and the grip is a document.body child at z-index 7,
|
|
7040
|
+
// so the grip won that hit test whenever it was showing — which is
|
|
7041
|
+
// whenever the pointer had recently been over a table cell. MEASURED on
|
|
7042
|
+
// v2.12.0 at 1400x900 with the row grip parked on the HEADER row (the only
|
|
7043
|
+
// row whose 28px-tall grip reaches the ⠿'s own top-20px band):
|
|
7044
|
+
// elementFromPoint() walked across the table block's ⠿ at its vertical
|
|
7045
|
+
// centre answered .ed-handle for x 390..401 and the ROW GRIP for x
|
|
7046
|
+
// 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
|
|
7047
|
+
// a table ROW drag instead of a block move.
|
|
7048
|
+
//
|
|
7049
|
+
// S4 T1 (v2.12.0) fixed that by INSETTING the grip instead — its left edge
|
|
7050
|
+
// on the table's left border, entirely inside the table, no more straddle.
|
|
7051
|
+
// That broke the row/column symmetry (the column grip still straddles the
|
|
7052
|
+
// table's top border, because nothing of the page's own chrome lives up
|
|
7053
|
+
// there) and was reported as a v3.0.1 visual regression. An even earlier
|
|
7054
|
+
// revision (v2.10.1) had already tried an inset like it and been reverted,
|
|
7055
|
+
// because a 20px grip inside a cell whose left padding is 14px sits on
|
|
7056
|
+
// that cell's TEXT — S4 T1 avoided repeating that by widening the first
|
|
7057
|
+
// column's own edit-mode padding to clear the inset grip.
|
|
7058
|
+
//
|
|
7059
|
+
// v3.0.1 undoes the inset rather than re-tuning it: the row grip goes back
|
|
7060
|
+
// to straddling the border (this file's change, see updateTableEdgeGrips()
|
|
7061
|
+
// below), and the ⠿/+ pair itself moves further LEFT instead (a shift
|
|
7062
|
+
// token declared alongside the gutter's own tokens in lib/md2doc.js) — the
|
|
7063
|
+
// overlap is removed at its SOURCE, not by moving the grip out of the
|
|
7064
|
+
// ⠿'s way. That also means the first column's edit-mode padding widening
|
|
7065
|
+
// is gone: the grip's straddling half (10px) fits inside the column's
|
|
7066
|
+
// default 14px padding on its own, so v2.10.1's original defect does not
|
|
7067
|
+
// come back. Neither the ⠿'s right edge nor the grip's left edge is
|
|
7068
|
+
// written down as a literal on either side of the gap between them — see
|
|
7069
|
+
// the shift-token comment in lib/md2doc.js's edit-mode :root block, which
|
|
7070
|
+
// this file deliberately does not name (test/editor-client.test.js forbids
|
|
7071
|
+
// the retired hover-gutter class name as a bare substring and the token
|
|
7072
|
+
// name contains it) — so the two cannot drift apart the way v2.11.0's pair
|
|
7073
|
+
// position once did (the CSS comment claiming "an 8px gap" was describing
|
|
7074
|
+
// that stale position, which v2.11.1 replaced).
|
|
7075
|
+
// test/editor-client-runtime.test.js asserts BOTH directions: every pixel
|
|
7076
|
+
// of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
|
|
7077
|
+
// grip's own centre answers the row grip.
|
|
6560
7078
|
// Either way the grip's hit rect DOES overlap the insert bubble's hit
|
|
6561
7079
|
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
6562
7080
|
// own axis). Non-intersection via rect separation is no longer possible
|
|
@@ -6572,6 +7090,19 @@
|
|
|
6572
7090
|
// reported overlap corner AND verifies the z-index ordering directly —
|
|
6573
7091
|
// re-verify it if either the grip's or bubble's z-index ever changes.
|
|
6574
7092
|
|
|
7093
|
+
// --ed-te-grip-row-w, read once and cached. The token is the single source
|
|
7094
|
+
// of truth for the grip's width (lib/md2doc.js declares it); parsing it here
|
|
7095
|
+
// means the JS never carries a second copy of the number.
|
|
7096
|
+
let gripRowWidthCache = null;
|
|
7097
|
+
function gripRowWidthPx() {
|
|
7098
|
+
if (gripRowWidthCache !== null) return gripRowWidthCache;
|
|
7099
|
+
const raw = getComputedStyle(document.documentElement)
|
|
7100
|
+
.getPropertyValue('--ed-te-grip-row-w');
|
|
7101
|
+
const n = parseFloat(raw);
|
|
7102
|
+
gripRowWidthCache = Number.isFinite(n) && n > 0 ? n : 20;
|
|
7103
|
+
return gripRowWidthCache;
|
|
7104
|
+
}
|
|
7105
|
+
|
|
6575
7106
|
function buildTableGrip(cls, ariaLabel) {
|
|
6576
7107
|
const b = document.createElement('button');
|
|
6577
7108
|
b.type = 'button';
|
|
@@ -6635,12 +7166,18 @@
|
|
|
6635
7166
|
// gripCenter(), which jump straight to the grip's own coordinates) could
|
|
6636
7167
|
// ever land on it; a real mouse gesture could not.
|
|
6637
7168
|
//
|
|
6638
|
-
// Current geometry (
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6641
|
-
//
|
|
6642
|
-
//
|
|
6643
|
-
// outside
|
|
7169
|
+
// Current geometry (v3.0.1 restored the row axis to mirror the column
|
|
7170
|
+
// axis — see the "Notion-style row/column grip handles" section comment
|
|
7171
|
+
// above for the S4 T1 detour into an inset row grip and how it was
|
|
7172
|
+
// undone): BOTH grips straddle their own border again, the COLUMN grip
|
|
7173
|
+
// half above / half below the table's TOP border and the ROW grip half
|
|
7174
|
+
// outside / half inside the table's LEFT border. The corridor-crossing
|
|
7175
|
+
// bug described above therefore applies to BOTH axes' straddling shape in
|
|
7176
|
+
// principle, and pointInColGripZone() still covers the column's
|
|
7177
|
+
// outside-the-border corridor exactly as before. pointInRowGripZone()
|
|
7178
|
+
// deliberately does NOT get an equivalent corridor formula back, even
|
|
7179
|
+
// though the straddle is back — see its own comment for why the padded
|
|
7180
|
+
// grip rect alone is still sufficient.
|
|
6644
7181
|
//
|
|
6645
7182
|
// Review fix (Important, first pass over-permissive): the first version of
|
|
6646
7183
|
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
@@ -6653,14 +7190,28 @@
|
|
|
6653
7190
|
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
6654
7191
|
// pointInColGripZone() below) instead of the whole table.
|
|
6655
7192
|
//
|
|
6656
|
-
// What each keep-zone covers TODAY:
|
|
6657
|
-
//
|
|
6658
|
-
// own
|
|
6659
|
-
//
|
|
6660
|
-
//
|
|
6661
|
-
//
|
|
6662
|
-
//
|
|
6663
|
-
// the
|
|
7193
|
+
// What each keep-zone covers TODAY:
|
|
7194
|
+
// pointInColGripZone() keeps the ORIGINAL corridor shape: the union of
|
|
7195
|
+
// (the column grip's own padded rect) and (the straight strip between
|
|
7196
|
+
// the grip's own TOP edge and the table's top border, x clamped to the
|
|
7197
|
+
// anchor COLUMN's own horizontal extent, padded). The column grip
|
|
7198
|
+
// straddles the top border, so that corridor is real.
|
|
7199
|
+
// pointInRowGripZone() is the padded grip rect ALONE, no corridor term —
|
|
7200
|
+
// kept that way through the v3.0.1 straddle restoration on purpose,
|
|
7201
|
+
// not left behind by accident. A pointer travelling from inside a body
|
|
7202
|
+
// cell to the row grip's own centre (which sits exactly on the
|
|
7203
|
+
// table's left border) never has to leave the table's horizontal
|
|
7204
|
+
// extent on the way there, so the padded rect alone already covers
|
|
7205
|
+
// every REAL (non-teleporting) approach this file's own test suite
|
|
7206
|
+
// exercises — see "table grips: the row grip straddles the table's
|
|
7207
|
+
// left border again…" in test/editor-client-runtime.test.js. That is
|
|
7208
|
+
// narrower than the column axis's guarantee (nothing approaches the
|
|
7209
|
+
// row grip from further out along the table's own left edge the way a
|
|
7210
|
+
// pointer can approach the column grip from above it), which is why
|
|
7211
|
+
// the two functions are allowed to stay asymmetric even though the
|
|
7212
|
+
// grips' shapes are not. If a future report finds a real pointer path
|
|
7213
|
+
// that does need a corridor term here, that is a new, separate defect
|
|
7214
|
+
// — not evidence this reasoning was wrong when it was written.
|
|
6664
7215
|
// A pointer outside either grip's own zone is a genuine exit and still
|
|
6665
7216
|
// hides the grip via hideTableGrips(), same as before. Neither fix touches
|
|
6666
7217
|
// either grip's size or z-index, so the click-priority guarantee (bubble
|
|
@@ -6680,15 +7231,33 @@
|
|
|
6680
7231
|
function pointInRowGripZone(x, y) {
|
|
6681
7232
|
if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
|
|
6682
7233
|
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
//
|
|
6686
|
-
//
|
|
6687
|
-
//
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
7234
|
+
// v3.0.1 恢復跨界:row grip 的中心線重新落在表格左邊界上(不再是
|
|
7235
|
+
// S4 T1 那個左緣頂在邊界上、整個縮進表格內側的做法)。與 ⠿ 的重疊
|
|
7236
|
+
// 改在源頭解決——往左再位移的是頁面 gutter 的 ⠿/+ 那一對按鈕本身
|
|
7237
|
+
// (多移動一個位移 token,宣告在 lib/md2doc.js 的編輯模式 :root 區
|
|
7238
|
+
// 塊,此檔案依慣例不直接引用其名稱),不是移動 grip。
|
|
7239
|
+
//
|
|
7240
|
+
// 走廊公式沒有跟著補回來,是刻意維持的範圍決定,不是漏掉:
|
|
7241
|
+
// TE_GRIP_ZONE_PAD_PX=4 的 padded rect 涵蓋 [tableLeft-14,
|
|
7242
|
+
// tableLeft+14](grip 本身 20px 寬、跨在邊界上是 [tableLeft-10,
|
|
7243
|
+
// tableLeft+10],外加 4px 緩衝)。指標從儲存格內側走向 grip 中心
|
|
7244
|
+
// (正好落在 tableLeft)的整段路徑,x 一律 >= tableLeft,從未離開
|
|
7245
|
+
// 表格的水平範圍,所以「儲存格 -> grip」這條測試套件唯一驗證過的
|
|
7246
|
+
// 真實(非瞬移)路徑,本來就不需要走廊公式。哪天真的量到一條需要
|
|
7247
|
+
// 走廊的真實路徑(例如從表格左側、gutter 那一側靠近),那是新的、
|
|
7248
|
+
// 獨立的缺陷,不代表這裡當初的推論錯了。
|
|
7249
|
+
//
|
|
7250
|
+
// 誠實記一筆巧合,不要誤讀成保證:⠿ 的右緣恰好落在 tableLeft-14,
|
|
7251
|
+
// 與這個 padded rect 的左緣相觸、不重疊——這是 TE_GRIP_ZONE_PAD_PX
|
|
7252
|
+
// 這個寫死的 4,與 lib/md2doc.js 那個 4px 呼吸間距 token 兩個各自
|
|
7253
|
+
// 獨立宣告出來的巧合,不是誰推導誰的保證。任一邊數值改動,觸點都
|
|
7254
|
+
// 可能變成 1px 縫隙或重疊,沒有任何型別檢查會抓到。這是既有狀態,
|
|
7255
|
+
// 不是本次修正引入或修掉的東西。
|
|
7256
|
+
//
|
|
7257
|
+
// 對使用者可見的差別:離開表格左側不再等於立刻收掉 row grip——padded
|
|
7258
|
+
// rect 本身就已經探出表格邊界 14px,所以指標要離開這整個 padded rect
|
|
7259
|
+
// (水平或垂直)才會真的收掉,錨定那一列的高度也不例外。
|
|
7260
|
+
return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
|
|
6692
7261
|
}
|
|
6693
7262
|
|
|
6694
7263
|
function pointInColGripZone(x, y) {
|
|
@@ -6717,9 +7286,10 @@
|
|
|
6717
7286
|
function updateTableEdgeGrips(x, y, target) {
|
|
6718
7287
|
// Both grips are `position: fixed` overlays appended to document.body
|
|
6719
7288
|
// (same as the hover-insert bubbles) rather than descendants of the
|
|
6720
|
-
// table —
|
|
6721
|
-
// border
|
|
6722
|
-
//
|
|
7289
|
+
// table — the column grip PAINTS half outside it (straddling the top
|
|
7290
|
+
// border) and the row grip paints inside the first column (§4.2
|
|
7291
|
+
// conflict 2). Either way neither is a table descendant, so the moment
|
|
7292
|
+
// the real pointer crosses from a cell onto
|
|
6723
7293
|
// the grip itself, `target` is the grip and is no
|
|
6724
7294
|
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
6725
7295
|
// Without this guard, that transition would hit the "nothing found"
|
|
@@ -6754,8 +7324,12 @@
|
|
|
6754
7324
|
// Row grip: every row, including the header — the first row of a
|
|
6755
7325
|
// markdown table IS the header, so any row must be draggable to the
|
|
6756
7326
|
// top to become it. ONE position rule for all of them (user acceptance:
|
|
6757
|
-
// 「grip 位置都一樣」) —
|
|
6758
|
-
//
|
|
7327
|
+
// 「grip 位置都一樣」) — its CENTRELINE on the table's left border, i.e.
|
|
7328
|
+
// straddling it half outside / half inside the table, restored in
|
|
7329
|
+
// v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
|
|
7330
|
+
// buildTableGrip() for the S4 T1 detour into an inset grip and how it
|
|
7331
|
+
// was undone), vertically centred on its own row; no
|
|
7332
|
+
// header special case. The
|
|
6759
7333
|
// one exception is a header-only table (no body rows): its single row
|
|
6760
7334
|
// is thead's only row, and dragging it away would empty the thead —
|
|
6761
7335
|
// serializeTable() would degrade it and the user's table would vanish
|
|
@@ -6764,12 +7338,20 @@
|
|
|
6764
7338
|
gripRowTableEl = tableEl;
|
|
6765
7339
|
gripRowEl = rowEl;
|
|
6766
7340
|
const r = rowEl.getBoundingClientRect();
|
|
6767
|
-
// Fallback
|
|
6768
|
-
// (
|
|
6769
|
-
//
|
|
7341
|
+
// Fallback height matches .ed-te-grip-row's own CSS height exactly
|
|
7342
|
+
// (28) — offsetHeight reads 0 while `hidden` (display: none) is still
|
|
7343
|
+
// true on the FIRST show of a hover session, before the
|
|
6770
7344
|
// `hidden = false` assignment below takes effect.
|
|
6771
|
-
const gw = rowGrip.offsetWidth || 20;
|
|
6772
7345
|
const gh = rowGrip.offsetHeight || 28;
|
|
7346
|
+
// v3.0.1: back to STRADDLING the table's left border (the S4 T1 inset
|
|
7347
|
+
// was a workaround for the ⠿ hit-test overlap; the ⠿/+ pair has
|
|
7348
|
+
// since moved further left via a CSS shift token in lib/md2doc.js,
|
|
7349
|
+
// which removes the overlap at its source). The width is read from the
|
|
7350
|
+
// token rather than written as a literal here: offsetWidth reads 0
|
|
7351
|
+
// while `hidden` is still true on the first show of a hover session,
|
|
7352
|
+
// and a hard-coded fallback is exactly the CSS/JS drift the token
|
|
7353
|
+
// exists to prevent.
|
|
7354
|
+
const gw = rowGrip.offsetWidth || gripRowWidthPx();
|
|
6773
7355
|
rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
|
|
6774
7356
|
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
6775
7357
|
rowGrip.hidden = false;
|
|
@@ -7124,6 +7706,820 @@
|
|
|
7124
7706
|
currentBurst.history.snap('drag-col');
|
|
7125
7707
|
}
|
|
7126
7708
|
|
|
7709
|
+
// ── S4 Task 2: dragging a BLOCK by its ⠿ handle (spec §4.5) ─────────────
|
|
7710
|
+
// The gesture SKELETON only — threshold, capture, drop-target computation,
|
|
7711
|
+
// the indicator, and every cancellation path. THE DROP IS A NO-OP; Tasks
|
|
7712
|
+
// 3-7 add the commits. §4.5 says to reuse the table row drag's skeleton
|
|
7713
|
+
// above, and this does, including its central invariant:
|
|
7714
|
+
//
|
|
7715
|
+
// NOTHING IN THE DOM IS MUTATED DURING A DRAG. Only the indicator moves.
|
|
7716
|
+
//
|
|
7717
|
+
// That is the whole reason cancelBlockDrag() can be called unconditionally
|
|
7718
|
+
// from Escape / pointercancel / window blur / the next pointerdown /
|
|
7719
|
+
// rerenderAll() / resolveBurst() with nothing to revert — see
|
|
7720
|
+
// cancelTeDrag()'s own comment for the same argument on the row axis. It
|
|
7721
|
+
// also means no `class=""` residue can reach a burst's zero-edit guard (the
|
|
7722
|
+
// row path had to strip one by hand, because it DIMS the dragged <tr>; this
|
|
7723
|
+
// gesture deliberately dims nothing, so the question never arises). Even the
|
|
7724
|
+
// ⠿ menu, which lives as a CHILD of its block and would therefore be a
|
|
7725
|
+
// content mutation to close, is left alone until the gesture is over.
|
|
7726
|
+
// S4 Task 2b: the drag-cursor class, on `document.documentElement`. Named
|
|
7727
|
+
// once so the add, the remove and lib/md2doc.js's rule cannot drift apart.
|
|
7728
|
+
const BLOCK_DRAGGING_CLASS = 'ed-block-dragging';
|
|
7729
|
+
const blockDropIndicator = document.createElement('div');
|
|
7730
|
+
blockDropIndicator.className = 'ed-block-drop-indicator';
|
|
7731
|
+
blockDropIndicator.hidden = true;
|
|
7732
|
+
document.body.appendChild(blockDropIndicator);
|
|
7733
|
+
|
|
7734
|
+
// The drop-target candidates: the document's block sequence, in document
|
|
7735
|
+
// order. MEASURED on this branch (1400x900, a doc mixing paragraphs, a
|
|
7736
|
+
// heading, a list run with a nested item, a same-line-nest PHANTOM, a
|
|
7737
|
+
// fenced code block and a table): every single `.ed-block` is a DIRECT
|
|
7738
|
+
// child of `contentEl` — S1 flattened the <ul>/<ol> containers away — so
|
|
7739
|
+
// allBlockEls() already IS that sequence, with no nesting to flatten and no
|
|
7740
|
+
// second, overlapping y-range sitting inside another candidate's box.
|
|
7741
|
+
// Named rather than inlined because Tasks 3-7 re-resolve against it.
|
|
7742
|
+
function blockDropCandidates() {
|
|
7743
|
+
return allBlockEls();
|
|
7744
|
+
}
|
|
7745
|
+
|
|
7746
|
+
// Nearest block-drop target for `clientY` — a discriminated union:
|
|
7747
|
+
// {mode: 'before-block', blockIndex, y} | {mode: 'append', y}
|
|
7748
|
+
// `blockIndex` is an ORDINAL into blockDropCandidates(); never a
|
|
7749
|
+
// `data-block-id` (blockmap.js renumbers 0..n-1 on every render, so a
|
|
7750
|
+
// captured id names the target's NEIGHBOUR after any commit that changes
|
|
7751
|
+
// the block count) and never a node (a mid-gesture commit detaches them).
|
|
7752
|
+
//
|
|
7753
|
+
// spec §4.6 replaced the table's `{beforeRow, y}` with exactly this shape,
|
|
7754
|
+
// because a null `beforeRow` had been overloaded to mean "append". That
|
|
7755
|
+
// ruling is followed here rather than nearestColDropTarget()'s flat
|
|
7756
|
+
// `{index, x}`, which is the one place it was never applied.
|
|
7757
|
+
//
|
|
7758
|
+
// There is no 'above-header' counterpart: a table's first row IS its
|
|
7759
|
+
// header, so promoting a row to first place needed its own mode; the
|
|
7760
|
+
// document has no such distinguished first block, and `before-block 0`
|
|
7761
|
+
// already says "become the new first block".
|
|
7762
|
+
function nearestBlockDropTarget(clientY) {
|
|
7763
|
+
const els = blockDropCandidates();
|
|
7764
|
+
for (let i = 0; i < els.length; i++) {
|
|
7765
|
+
const r = els[i].getBoundingClientRect();
|
|
7766
|
+
if (clientY < r.top + r.height / 2) {
|
|
7767
|
+
return { mode: 'before-block', blockIndex: i, y: r.top };
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7770
|
+
const last = els[els.length - 1];
|
|
7771
|
+
const rect = last ? last.getBoundingClientRect() : contentEl.getBoundingClientRect();
|
|
7772
|
+
return { mode: 'append', y: rect.bottom };
|
|
7773
|
+
}
|
|
7774
|
+
|
|
7775
|
+
// The in-flight ⠿ gesture, or null between gestures — the block-axis
|
|
7776
|
+
// counterpart of `tePointer`. `sourceIndex` is the ORDINAL of the pressed
|
|
7777
|
+
// block AT PRESS TIME, which is the table drop's own rule ("convert the
|
|
7778
|
+
// pressed node to an ordinal first, re-resolve in the live DOM at drop
|
|
7779
|
+
// time"): between the press and the release, resolveGutterOperands()'s
|
|
7780
|
+
// switchAwayFrom() may commit some other dirty burst, re-render, and detach
|
|
7781
|
+
// every node in the document. `dragging` flips true once
|
|
7782
|
+
// TE_DRAG_THRESHOLD_PX is crossed; `dropTarget` is the union above, filled
|
|
7783
|
+
// in by updateBlockDropIndicator() as the pointer moves.
|
|
7784
|
+
let blockDragState = null;
|
|
7785
|
+
|
|
7786
|
+
// Arms the gesture for a press on a ⠿. Returns TRUE when it owns this
|
|
7787
|
+
// pointer, so the `pointerdown` listener below can stop before it reaches
|
|
7788
|
+
// beginShiftClickSelection()/armBlockSelDrag().
|
|
7789
|
+
//
|
|
7790
|
+
// Running FIRST is required and is also free: `.ed-handle` is a member of
|
|
7791
|
+
// ED_SEL_GESTURE_CHROME, so armBlockSelDrag()'s very first line already
|
|
7792
|
+
// returned for exactly this target (and beginShiftClickSelection()'s does
|
|
7793
|
+
// too) — which is why a press on the ⠿ recorded NO pointer state at all
|
|
7794
|
+
// before this task. Nothing about a press anywhere else changes.
|
|
7795
|
+
//
|
|
7796
|
+
// No preventDefault() here, deliberately. wireBlockSelection()'s delegated
|
|
7797
|
+
// `mousedown` preventDefault() on `.ed-handle` is what keeps a dirty
|
|
7798
|
+
// burst's focus intact across a ⠿ press — without it the press blurs the
|
|
7799
|
+
// burst, the blur commits, the commit re-renders, the button is detached
|
|
7800
|
+
// between mousedown and mouseup and the `click` never fires at all (the
|
|
7801
|
+
// swallowed-first-click defect that fix exists for). Cancelling
|
|
7802
|
+
// `pointerdown` additionally suppresses the compatibility `mousedown` in
|
|
7803
|
+
// some engines, i.e. the very event that fix lives on. updateBlockDrag()
|
|
7804
|
+
// does the preventDefault() instead, once the gesture is definitely a drag.
|
|
7805
|
+
function armBlockDrag(e) {
|
|
7806
|
+
const handleEl = e.target && e.target.closest ? e.target.closest('.ed-handle') : null;
|
|
7807
|
+
if (!handleEl) return false;
|
|
7808
|
+
const blockEl = handleEl.closest('.ed-block');
|
|
7809
|
+
if (!blockEl) return false;
|
|
7810
|
+
const index = blockDropCandidates().indexOf(blockEl);
|
|
7811
|
+
if (index < 0) return false;
|
|
7812
|
+
blockDragState = {
|
|
7813
|
+
pointerId: e.pointerId,
|
|
7814
|
+
sourceIndex: index,
|
|
7815
|
+
// Carried for Tasks 3-7's own re-resolution cross-check only; the
|
|
7816
|
+
// ORDINAL above is the identity, for the renumbering reason spelt out
|
|
7817
|
+
// on `blockDragState`.
|
|
7818
|
+
sourceBlockId: blockEl.getAttribute('data-block-id'),
|
|
7819
|
+
startX: e.clientX, startY: e.clientY,
|
|
7820
|
+
dragging: false,
|
|
7821
|
+
dropTarget: null,
|
|
7822
|
+
captureEl: null,
|
|
7823
|
+
};
|
|
7824
|
+
return true;
|
|
7825
|
+
}
|
|
7826
|
+
|
|
7827
|
+
// Repositions the singleton line onto the current drop target. All four
|
|
7828
|
+
// dimensions are written on every move — the node is this gesture's alone
|
|
7829
|
+
// (see the class comment in lib/md2doc.js for why it is not the table's),
|
|
7830
|
+
// but the content column can be resized by a sidebar drag or a window
|
|
7831
|
+
// resize between two frames, so a partially-written box would leave a stale
|
|
7832
|
+
// width behind.
|
|
7833
|
+
function updateBlockDropIndicator(clientY) {
|
|
7834
|
+
const target = nearestBlockDropTarget(clientY);
|
|
7835
|
+
blockDragState.dropTarget = target;
|
|
7836
|
+
const els = blockDropCandidates();
|
|
7837
|
+
const spanEl = target.mode === 'append' ? els[els.length - 1] : els[target.blockIndex];
|
|
7838
|
+
const r = spanEl ? spanEl.getBoundingClientRect() : contentEl.getBoundingClientRect();
|
|
7839
|
+
blockDropIndicator.style.left = r.left + 'px';
|
|
7840
|
+
blockDropIndicator.style.width = r.width + 'px';
|
|
7841
|
+
blockDropIndicator.style.top = (target.y - 1) + 'px';
|
|
7842
|
+
blockDropIndicator.style.height = '3px';
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
// Capture on documentElement at ENGAGE time, not at press time — the S3
|
|
7846
|
+
// selection drag's reasoning (captureBlockSelDrag()) applies verbatim here
|
|
7847
|
+
// and the table's does not. A press on a ⠿ that turns out to be a plain
|
|
7848
|
+
// click must not be interfered with AT ALL: it has to reach `click` and open
|
|
7849
|
+
// the menu. And the pressed node is a per-block button that rerenderAll()
|
|
7850
|
+
// and openRawEditor()'s restore() both re-create, so capturing on it would
|
|
7851
|
+
// pin a node the gesture can outlive.
|
|
7852
|
+
function captureBlockDrag(e) {
|
|
7853
|
+
const el = document.documentElement;
|
|
7854
|
+
if (el && typeof el.setPointerCapture === 'function') {
|
|
7855
|
+
try { el.setPointerCapture(e.pointerId); blockDragState.captureEl = el; }
|
|
7856
|
+
catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
|
|
7857
|
+
}
|
|
7858
|
+
}
|
|
7859
|
+
|
|
7860
|
+
function releaseBlockDragCapture(st) {
|
|
7861
|
+
if (st && st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
|
|
7862
|
+
try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
|
|
7863
|
+
}
|
|
7864
|
+
}
|
|
7865
|
+
|
|
7866
|
+
function updateBlockDrag(e) {
|
|
7867
|
+
if (!blockDragState || e.pointerId !== blockDragState.pointerId) return;
|
|
7868
|
+
if (tePointer) return; // a grip gesture owns this pointer
|
|
7869
|
+
// The browser can simply never deliver a pointerup (released over browser
|
|
7870
|
+
// chrome, over another window). The next move with no button held is the
|
|
7871
|
+
// only signal left that the gesture is over — the same guard
|
|
7872
|
+
// updateBlockSelDrag() carries, for the same reason.
|
|
7873
|
+
// S4 Task 3: endBlockDrag() is async now. Deliberately not awaited here —
|
|
7874
|
+
// `updateBlockDrag` is called from a `pointermove` listener that cannot
|
|
7875
|
+
// await, and this branch is the last-resort recovery for a pointerup the
|
|
7876
|
+
// browser never delivered. The teardown inside it is synchronous either
|
|
7877
|
+
// way; only the drop's own commit trails.
|
|
7878
|
+
if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockDrag(); return; }
|
|
7879
|
+
if (!blockDragState.dragging) {
|
|
7880
|
+
const dx = e.clientX - blockDragState.startX, dy = e.clientY - blockDragState.startY;
|
|
7881
|
+
if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
|
|
7882
|
+
blockDragState.dragging = true;
|
|
7883
|
+
captureBlockDrag(e);
|
|
7884
|
+
blockDropIndicator.hidden = false;
|
|
7885
|
+
// S4 Task 2b (user request, 2026-09-01): the ONLY feedback the source
|
|
7886
|
+
// block gets. `.ed-handle` is `opacity: 0` off-hover, so from the moment
|
|
7887
|
+
// the pointer leaves the block there is nothing under the cursor but the
|
|
7888
|
+
// indicator line. The ruling is a `cursor: grabbing` class on
|
|
7889
|
+
// `document.documentElement` — OUTSIDE `contentEl`, so the "mutate
|
|
7890
|
+
// nothing in the content DOM during a drag" invariant this whole gesture
|
|
7891
|
+
// rests on still holds, and every cancellation path stays unconditional.
|
|
7892
|
+
// The table drag's `ed-te-row-dragging` dim is deliberately NOT the
|
|
7893
|
+
// model: it dims a node INSIDE the content, and the burst's zero-edit
|
|
7894
|
+
// guard compares `contentEl`'s innerHTML, so the residue it leaves had
|
|
7895
|
+
// to be stripped by hand.
|
|
7896
|
+
//
|
|
7897
|
+
// ⚠ CORRECTED (S4 review round, 2026-09-01). The earlier wording here
|
|
7898
|
+
// and in §4.5 said documentElement AVOIDS that `class=""` residue. It
|
|
7899
|
+
// does not. classList.add() on an element with no `class` attribute
|
|
7900
|
+
// CREATES one and remove() only empties its value — measured in the same
|
|
7901
|
+
// Chromium: `<html class="">` after every engaged drag, every time. The
|
|
7902
|
+
// residue is not avoided, it is RELOCATED: `<html>` is a node nothing in
|
|
7903
|
+
// this file compares, `contentEl.innerHTML` is a string the zero-edit
|
|
7904
|
+
// guard reads on every commit. The ruling is unchanged (the cost really
|
|
7905
|
+
// is zero); only its stated reason was wrong.
|
|
7906
|
+
document.documentElement.classList.add(BLOCK_DRAGGING_CLASS);
|
|
7907
|
+
// Nothing else is touched here. The table's hover chrome (insert
|
|
7908
|
+
// bubbles, grips) is hidden by the rAF `mousemove` gate below, which is
|
|
7909
|
+
// where the row drag and the S3 selection drag both do it too; and the
|
|
7910
|
+
// ⠿ / + menus are left alone until endBlockDrag(), because closing one
|
|
7911
|
+
// detaches a node from INSIDE a block and would break the
|
|
7912
|
+
// "nothing is mutated during a drag" invariant this whole gesture rests
|
|
7913
|
+
// on.
|
|
7914
|
+
}
|
|
7915
|
+
e.preventDefault();
|
|
7916
|
+
updateBlockDropIndicator(e.clientY);
|
|
7917
|
+
}
|
|
7918
|
+
|
|
7919
|
+
// Shared teardown. Both exits below go through it, so a future drop added
|
|
7920
|
+
// to endBlockDrag() can never be reached from a cancellation path.
|
|
7921
|
+
function teardownBlockDrag() {
|
|
7922
|
+
if (!blockDragState) return null;
|
|
7923
|
+
const st = blockDragState;
|
|
7924
|
+
blockDragState = null;
|
|
7925
|
+
releaseBlockDragCapture(st);
|
|
7926
|
+
blockDropIndicator.hidden = true;
|
|
7927
|
+
// S4 Task 2b: removed HERE and nowhere else. T2 routed endBlockDrag() and
|
|
7928
|
+
// cancelBlockDrag() through this one function precisely so a later
|
|
7929
|
+
// addition inherits all five abort paths (pointerup, Escape,
|
|
7930
|
+
// pointercancel, window blur, the next pointerdown / every re-render) for
|
|
7931
|
+
// free. Unconditional on `.dragging`: classList.remove() of a class that
|
|
7932
|
+
// was never added is a no-op, and a gesture that ended before the
|
|
7933
|
+
// threshold never added it.
|
|
7934
|
+
document.documentElement.classList.remove(BLOCK_DRAGGING_CLASS);
|
|
7935
|
+
// A gesture that became a drag owns its own trailing `click`: released
|
|
7936
|
+
// outside every block it would reach wireBlockSelection()'s
|
|
7937
|
+
// switchAwayFrom(); released back over the ⠿ it started on it would OPEN
|
|
7938
|
+
// THE MENU the user was not asking for. Same one-shot flag the S3
|
|
7939
|
+
// selection drag sets, re-armed to false by the next pointerdown so a
|
|
7940
|
+
// gesture whose click never arrives cannot swallow a later one.
|
|
7941
|
+
if (st.dragging) blockSelClickSuppressed = true;
|
|
7942
|
+
return st;
|
|
7943
|
+
}
|
|
7944
|
+
|
|
7945
|
+
// The `pointerup` half. S4 Task 3 makes the drop REAL for a single block
|
|
7946
|
+
// that is not a list item; Tasks 4-7 widen it. The teardown above is
|
|
7947
|
+
// synchronous and happens FIRST, so the gesture is already over (indicator
|
|
7948
|
+
// down, capture released, `ed-block-dragging` off <html>) before a single
|
|
7949
|
+
// byte is considered — nothing the commit does can leave drag state behind.
|
|
7950
|
+
//
|
|
7951
|
+
// ASYNC, and the `pointerup` listener awaits it: the drop resolves an open
|
|
7952
|
+
// burst and re-renders, and a fire-and-forget promise would let a test (or
|
|
7953
|
+
// the next gesture) observe the document between the release and the
|
|
7954
|
+
// commit's own /api/render.
|
|
7955
|
+
async function endBlockDrag() {
|
|
7956
|
+
const st = teardownBlockDrag();
|
|
7957
|
+
if (!st || !st.dragging) return;
|
|
7958
|
+
// The menus are closed AFTER the gesture rather than at engage: closing
|
|
7959
|
+
// one detaches a node from inside a block, and mutating nothing during a
|
|
7960
|
+
// drag is what makes every cancellation path above unconditional.
|
|
7961
|
+
closeGutterMenu();
|
|
7962
|
+
closeInsertMenu();
|
|
7963
|
+
await performBlockDrop(st);
|
|
7964
|
+
}
|
|
7965
|
+
|
|
7966
|
+
// ── S4 Task 3: the drop (spec §4.5) ───────────────────────────────────
|
|
7967
|
+
// Everything up to here has been chrome. This is where the ⠿ drag writes
|
|
7968
|
+
// bytes, through the pure commitBlockMove() above: ONE commitRangeEdit over
|
|
7969
|
+
// the min..max span, hence exactly one Ctrl+Z.
|
|
7970
|
+
//
|
|
7971
|
+
// The pressed block is named by ORDINAL, never by node and never by id —
|
|
7972
|
+
// the table drop's own rule. `blockmap.js` renumbers ids 0..n-1 on every
|
|
7973
|
+
// render, so a captured id names the target's NEIGHBOUR after any commit
|
|
7974
|
+
// that changes the block count, and every node in the document is detached
|
|
7975
|
+
// by a re-render. The ordinal is resolved in the LIVE DOM here, before
|
|
7976
|
+
// resolveGutterOperands() is allowed to commit anything.
|
|
7977
|
+
//
|
|
7978
|
+
// The DESTINATION needs the same protection and gets it one step earlier:
|
|
7979
|
+
// its identity is captured BEFORE resolveGutterOperands(), whose
|
|
7980
|
+
// switchAwayFrom() may commit some other block's dirty burst and re-render.
|
|
7981
|
+
// reresolveBlockEl() then proves the block sitting at that start line
|
|
7982
|
+
// afterwards really is the same one, exactly as the source's own re-resolve
|
|
7983
|
+
// does; when it cannot, the gesture is DROPPED with a banner rather than
|
|
7984
|
+
// completed against a block the user did not point at.
|
|
7985
|
+
async function performBlockDrop(st) {
|
|
7986
|
+
const target = st.dropTarget;
|
|
7987
|
+
// ⚠ S4 Task 6 audited this for §3.6 (「靜默不動作是缺陷」) and it is the one
|
|
7988
|
+
// silent exit in this function that NO GESTURE CAN REACH: performBlockDrop()
|
|
7989
|
+
// is called only from endBlockDrag()'s `st.dragging` branch, engaging sets
|
|
7990
|
+
// `dragging` and then calls updateBlockDropIndicator(), which assigns
|
|
7991
|
+
// `dropTarget` UNCONDITIONALLY from nearestBlockDropTarget() — and that
|
|
7992
|
+
// function has no null answer at all, falling through to
|
|
7993
|
+
// `{mode:'append'}` when no block's mid-line is below the pointer. Kept as
|
|
7994
|
+
// a defensive guard, not as a behaviour.
|
|
7995
|
+
if (!target) return; // engaged but never moved onto a target — nothing to do
|
|
7996
|
+
const els = blockDropCandidates();
|
|
7997
|
+
const sourceEl = els[st.sourceIndex];
|
|
7998
|
+
const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
|
|
7999
|
+
if (!sourceEl || (target.mode !== 'append' && !destEl)) {
|
|
8000
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
8001
|
+
return;
|
|
8002
|
+
}
|
|
8003
|
+
const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
|
|
8004
|
+
// ...and the SAME question resolveGutterOperands() asks about the source,
|
|
8005
|
+
// asked about the destination, BEFORE the commit that answers it.
|
|
8006
|
+
//
|
|
8007
|
+
// S4 review round (2026-09-01), MEDIUM: a drop onto a block with an open
|
|
8008
|
+
// DIRTY burst was ALWAYS refused. Measured on
|
|
8009
|
+
// '# Doc\n\nalpha\n\nbravo\n\ncharlie\n' — type ' EDITED' into
|
|
8010
|
+
// `charlie`, drag `alpha` onto charlie's top edge — and the answer was
|
|
8011
|
+
// '文件已更新,請重試這個操作' with the edit committed and the move not.
|
|
8012
|
+
//
|
|
8013
|
+
// It is an ASYMMETRY, not a race, and it could not be anything else:
|
|
8014
|
+
// reresolveBlockEl()'s fingerprint is the block's SOURCE, and the commit
|
|
8015
|
+
// that changed that source is this drop's OWN resolveGutterOperands() →
|
|
8016
|
+
// switchAwayFrom(). The fingerprint is therefore GUARANTEED to miss. The
|
|
8017
|
+
// source side has had reresolveBlockElAfterSelfCommit() for exactly this
|
|
8018
|
+
// since S2; this is its destination-side counterpart, gated the same way
|
|
8019
|
+
// — on ownsOpenSession() read BEFORE the commit, because `currentBurst` is
|
|
8020
|
+
// gone afterwards — so reresolveBlockEl() keeps its fingerprint for
|
|
8021
|
+
// everybody else. Deliberately NOT a widening of that function: S2's own
|
|
8022
|
+
// note says the narrowed variant exists precisely so no other caller loses
|
|
8023
|
+
// the fingerprint.
|
|
8024
|
+
const destSelfSession = destEl ? ownsOpenSession(destEl) : false;
|
|
8025
|
+
// The shared gutter preamble — identity capture, switchAwayFrom(), the
|
|
8026
|
+
// re-resolve after a commit that re-rendered, the no-source-line refusal
|
|
8027
|
+
// and §3.3's membership. Not re-implemented here: 「不得另寫一條」.
|
|
8028
|
+
const operands = await resolveGutterOperands(sourceEl);
|
|
8029
|
+
if (!operands) return;
|
|
8030
|
+
let liveDestEl = destEl;
|
|
8031
|
+
if (destEl && !document.body.contains(destEl)) {
|
|
8032
|
+
liveDestEl = reresolveBlockEl(destIdentity) ||
|
|
8033
|
+
(destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
|
|
8034
|
+
if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8035
|
+
}
|
|
8036
|
+
// ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
|
|
8037
|
+
// §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
|
|
8038
|
+
// this read `if (operands.batch) return;` — the last two SILENT shapes in
|
|
8039
|
+
// the drag path, which §3.6 calls a defect in so many words
|
|
8040
|
+
// (「靜默不動作是缺陷」). From here down `opEls` is a span of one OR MORE
|
|
8041
|
+
// blocks and every path takes it whole; nothing loops over it, for the
|
|
8042
|
+
// reason convertBlockViaMenu() states — a commit between two members
|
|
8043
|
+
// re-renders and invalidates every id in between.
|
|
8044
|
+
const opEls = operands.els;
|
|
8045
|
+
const opRecs = operands.recs;
|
|
8046
|
+
// ── the FIRST home position, and it is shared by every kind and size ──
|
|
8047
|
+
// A drop that lands where the block already is is a silent byte no-op —
|
|
8048
|
+
// NOT a refusal and NOT a dropped gesture. It has to be answered before
|
|
8049
|
+
// every gate, and MEASURED, because the gates get it wrong on their own:
|
|
8050
|
+
// `before-block <sourceIndex>` — released on the block's own top edge,
|
|
8051
|
+
// which is exactly what the drag's "released where it started" scenario
|
|
8052
|
+
// drives — makes the destination the SOURCE, and the destination's index
|
|
8053
|
+
// in the source-less array below is then -1, i.e. the
|
|
8054
|
+
// '文件已更新,請重試這個操作' banner, for a gesture in which nothing
|
|
8055
|
+
// whatsoever went wrong. Caught by driving a real gesture and reading the
|
|
8056
|
+
// banner back; the byte assertions could not see it, because the file is
|
|
8057
|
+
// untouched either way. For a SET the same question is membership: any
|
|
8058
|
+
// member's top edge is the set's own footprint. The li path below has a
|
|
8059
|
+
// SECOND home position of its own — see performListItemDrop().
|
|
8060
|
+
if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
|
|
8061
|
+
const shape = spanListKinds(opEls);
|
|
8062
|
+
// §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
|
|
8063
|
+
// A mixed span would need a blank-line rule at the run/non-run seam — the
|
|
8064
|
+
// li half has to stay tight while the non-li half has to keep its
|
|
8065
|
+
// separators, and inside ONE commit range those two demands conflict. §3.6
|
|
8066
|
+
// records that the spec does not have that rule and that inventing it in
|
|
8067
|
+
// the last stage repeats the §3.4 marker-width error, so this refuses with
|
|
8068
|
+
// 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
|
|
8069
|
+
//
|
|
8070
|
+
// DELIBERATELY ABOVE the second home position, which is the one place this
|
|
8071
|
+
// function departs from Task 3's "answer a home position before every
|
|
8072
|
+
// gate" rule, and the departure is the point: a seam objection depends on
|
|
8073
|
+
// where the user AIMED, so refusing a put-it-back gesture with one would
|
|
8074
|
+
// be a banner for a gesture in which nothing went wrong. MIXED is a
|
|
8075
|
+
// property of the SET — it gives the same answer at every destination, so
|
|
8076
|
+
// there is no aim that would have worked and saying so is the honest
|
|
8077
|
+
// outcome. (The drop-on-a-member case is already out, above.)
|
|
8078
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
8079
|
+
// S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
|
|
8080
|
+
// relocated verbatim (markers and ordinals are run-global), so the whole
|
|
8081
|
+
// run is re-serialized in the new span order instead — still ONE
|
|
8082
|
+
// commitRangeEdit, still one Ctrl+Z. `shape.allLi` rather than the first
|
|
8083
|
+
// member's type: a set is all-li or it is mixed, and mixed is already out.
|
|
8084
|
+
// Everything below this line is the non-li move.
|
|
8085
|
+
if (shape.allLi) {
|
|
8086
|
+
await performListItemDrop(opEls, liveDestEl);
|
|
8087
|
+
return;
|
|
8088
|
+
}
|
|
8089
|
+
// ── the SECOND home position, still before every gate ──────────────
|
|
8090
|
+
// `before-block <sourceIndex + 1>` — the seam immediately below, the same
|
|
8091
|
+
// position on screen — is a no-op planBlockMove() answers null for, but
|
|
8092
|
+
// the list-seam gate below would have refused it OUT LOUD first whenever
|
|
8093
|
+
// the block sits between two list items. (The first home position is
|
|
8094
|
+
// answered above, before the li branch, because both kinds share it.)
|
|
8095
|
+
const live = blockDropCandidates();
|
|
8096
|
+
// The set's own two edges. `si` is its FIRST member and `sj` its LAST —
|
|
8097
|
+
// the source seam is what stands OUTSIDE those, and asking `si + 1` for it
|
|
8098
|
+
// would name the set's second member on any set of more than one.
|
|
8099
|
+
const si = live.indexOf(opEls[0]);
|
|
8100
|
+
const sj = live.indexOf(opEls[opEls.length - 1]);
|
|
8101
|
+
const rest = live.filter((el) => opEls.indexOf(el) === -1);
|
|
8102
|
+
const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
|
|
8103
|
+
if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8104
|
+
// §3.3's set is contiguous in block order — resolveGutterOperands()'s own
|
|
8105
|
+
// gate — so its members' lines plus the separators between them are ONE
|
|
8106
|
+
// range, which is what makes a batch move the same single commitRangeEdit
|
|
8107
|
+
// (and the same single Ctrl+Z) a one-block move is. See spanMoveRange().
|
|
8108
|
+
const srcRec = spanMoveRange(opRecs);
|
|
8109
|
+
const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
|
|
8110
|
+
if (!srcRec || (liveDestEl && !destRec)) {
|
|
8111
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
8112
|
+
return;
|
|
8113
|
+
}
|
|
8114
|
+
// A destination that owns no source line has an INVERTED range, and its
|
|
8115
|
+
// startLine names the line its OWNER occupies — inserting there would put
|
|
8116
|
+
// the block inside somebody else's territory.
|
|
8117
|
+
//
|
|
8118
|
+
// ⚠ S4 Task 6 MEASURED that this is REACHABLE, and that Task 3's comment
|
|
8119
|
+
// here ("unreachable while the seam gate below holds") was wrong. It said
|
|
8120
|
+
// so on the grounds that every no-line phantom this model produces is a
|
|
8121
|
+
// li — true, and beside the point: the seam gate refuses a destination
|
|
8122
|
+
// whose BOTH neighbours are li of one run, and a phantom's upper
|
|
8123
|
+
// neighbour is very often not a li at all. Driven as a real gesture on
|
|
8124
|
+
// '# Doc\n\npara\n\n- - b\n- c\n\ntail\n' (blockmap:
|
|
8125
|
+
// heading{1,1} | para{3,3} | li{5,4} PHANTOM | li{5,5} | li{6,6} |
|
|
8126
|
+
// para{8,8}): the phantom has a box of its own (measured top 190,
|
|
8127
|
+
// bottom 215 at 1400x900), so nearestBlockDropTarget() names it like any
|
|
8128
|
+
// other block; `rest[di - 1]` is the PARAGRAPH, so blockMoveSeamRefusal()
|
|
8129
|
+
// answers null; and planBlockMove() answers a REAL plan for it
|
|
8130
|
+
// ('# Doc\n\npara\n\ntail\n\n- - b\n- c\n'), so it is not a home
|
|
8131
|
+
// position either. Dragging `tail` onto that phantom therefore did
|
|
8132
|
+
// nothing, wrote nothing and said nothing — §3.6's 「靜默不動作是缺陷」,
|
|
8133
|
+
// and the third silent shape this task owed on top of the two the plan
|
|
8134
|
+
// named.
|
|
8135
|
+
//
|
|
8136
|
+
// The banner is the DESTINATION seam's, not a fifth constant: the line
|
|
8137
|
+
// this drop names belongs to the phantom's own nested item, so the block
|
|
8138
|
+
// really would land between an item and its sublist, which is exactly
|
|
8139
|
+
// what 無法把區塊放進清單項目之間 says. The remedy it gives (aim
|
|
8140
|
+
// somewhere else) is the correct one.
|
|
8141
|
+
if (destRec && destRec.endLine < destRec.startLine) {
|
|
8142
|
+
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
8143
|
+
return;
|
|
8144
|
+
}
|
|
8145
|
+
// The second home position (see above): `null` means "already there".
|
|
8146
|
+
// Asked BEFORE the seam gate so a put-it-back gesture is silent rather
|
|
8147
|
+
// than refused. planBlockMove() is pure, so asking twice costs nothing.
|
|
8148
|
+
if (!planBlockMove(lines, blocks, srcRec, destRec)) return;
|
|
8149
|
+
// Both seams MEASURED with marked.lexer, not reasoned about:
|
|
8150
|
+
// destination — '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para`
|
|
8151
|
+
// spliced between the two items lexes as list(1 item) | paragraph |
|
|
8152
|
+
// list(1 item): ONE two-item run became TWO one-item runs, and the
|
|
8153
|
+
// blank lines rule 3 emits are §4.3's looseness trap;
|
|
8154
|
+
// source — '# Doc\n\n- a\n\npara\n\n- b\n' with `para` lifted out
|
|
8155
|
+
// leaves '- a\n\n- b\n', which lexes as ONE list, loose === TRUE.
|
|
8156
|
+
// Every item then renders as <p>, serializeBlocks() reports 'P' for
|
|
8157
|
+
// each, and the whole run degrades read-only WITH NO BANNER.
|
|
8158
|
+
// S4 Task 5 NARROWED the destination half of this and deliberately left
|
|
8159
|
+
// the source half wide; blockMoveSeamRefusal()'s own header carries the
|
|
8160
|
+
// full enumeration and the measurement behind every row of it. The short
|
|
8161
|
+
// version: an insertion can only SPLIT, so the destination only has to
|
|
8162
|
+
// refuse a seam INSIDE one run; a removal MERGES, and the model does not
|
|
8163
|
+
// carry the raw bytes (the bullet character, the leading two spaces) that
|
|
8164
|
+
// decide whether two runs about to become adjacent stay two lists.
|
|
8165
|
+
//
|
|
8166
|
+
// The descriptor is built from listRunOf() — §3.4 rule 2's scope — so a
|
|
8167
|
+
// nested child and its parent answer the same run key, which is what
|
|
8168
|
+
// stops "a paragraph dropped between an item and its own sublist" from
|
|
8169
|
+
// reading as a boundary between two separate lists.
|
|
8170
|
+
const seamDesc = (el) => {
|
|
8171
|
+
if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
|
|
8172
|
+
const r = listRunOf(el);
|
|
8173
|
+
return { runKey: r.length ? r[0].getAttribute('data-block-id') : null };
|
|
8174
|
+
};
|
|
8175
|
+
const seamRefusal = blockMoveSeamRefusal(
|
|
8176
|
+
{ prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
|
|
8177
|
+
{ prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
|
|
8178
|
+
if (seamRefusal === 'source') {
|
|
8179
|
+
refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
|
|
8180
|
+
return;
|
|
8181
|
+
}
|
|
8182
|
+
if (seamRefusal === 'destination') {
|
|
8183
|
+
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
8184
|
+
return;
|
|
8185
|
+
}
|
|
8186
|
+
const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
|
|
8187
|
+
// commitRangeEdit()'s own unchanged-text answer, or a refused inverted
|
|
8188
|
+
// range. Neither pushed an op, so there is nothing to render or roll back.
|
|
8189
|
+
//
|
|
8190
|
+
// ⚠ S4 Task 6 audited this too, and corrected Task 3's claim that neither
|
|
8191
|
+
// is reachable. The inverted-range half is not (planBlockMove() has
|
|
8192
|
+
// already answered a real plan). The UNCHANGED-TEXT half is: MEASURED on
|
|
8193
|
+
// 'same\n\nsame\n\ntail\n' with the first paragraph moved past the
|
|
8194
|
+
// second, planBlockMove() answers a genuine plan and the text it produces
|
|
8195
|
+
// is the file byte-for-byte, because the two blocks are identical. That
|
|
8196
|
+
// is a real byte no-op — the document after the gesture IS the document
|
|
8197
|
+
// before it — so it stays silent for Task 3's own reason. A banner here
|
|
8198
|
+
// would tell a user that a move they can SEE succeeded had failed.
|
|
8199
|
+
if (!result.op) return;
|
|
8200
|
+
const prevLines = lines;
|
|
8201
|
+
lines = result.lines;
|
|
8202
|
+
// §3.3's collapse, declared IMMEDIATELY before the render and never
|
|
8203
|
+
// earlier: the moved block's range in the NEW document, which is where a
|
|
8204
|
+
// standing selection must end up. declareCollapse()'s own `if
|
|
8205
|
+
// (blockSelection)` guard is what stops this single-block gesture from
|
|
8206
|
+
// INVENTING a selection when none was standing.
|
|
8207
|
+
declareCollapse(result.newRange);
|
|
8208
|
+
const okRender = await safeRerenderAll();
|
|
8209
|
+
if (!okRender) {
|
|
8210
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
8211
|
+
}
|
|
8212
|
+
}
|
|
8213
|
+
|
|
8214
|
+
// ── S4 Task 4: the drop of a LIST ITEM, inside its own run (spec §4.5) ──
|
|
8215
|
+
//
|
|
8216
|
+
// Task 3's drop returned SILENTLY for a li. §3.6 is explicit that
|
|
8217
|
+
// 「靜默不動作是缺陷」, so after this function a li drag either MOVES or
|
|
8218
|
+
// refuses WITH A BANNER; the only silent outcome left is a home position,
|
|
8219
|
+
// which is Task 3's own ruling (a drop where the block already is is a byte
|
|
8220
|
+
// no-op, not a refusal) and is answered in two places here.
|
|
8221
|
+
//
|
|
8222
|
+
// THE SHAPE IS duplicateListItems()'s, and the recon named it as the only
|
|
8223
|
+
// existing path that already expresses a reorder as ONE commit:
|
|
8224
|
+
// * capture `presetRange` BEFORE mutating — runRangeOfBlocks() takes the
|
|
8225
|
+
// FIRST and LAST members of the array it is handed on the written
|
|
8226
|
+
// assumption that the array is in DOCUMENT order, which the reordered
|
|
8227
|
+
// span is not;
|
|
8228
|
+
// * build the REORDERED SPAN ARRAY (serializeBlocks() emits in array
|
|
8229
|
+
// order, and rebuilds its width/counter/type stacks as it walks — the
|
|
8230
|
+
// output is entirely determined by that order);
|
|
8231
|
+
// * mutateListRun() to move the node, with the focusout suppressed;
|
|
8232
|
+
// * commitListStructure(span, …, {presetRange, carryOver}), where
|
|
8233
|
+
// `carryOver` is bystanderCarryOver(span) — built AFTER the reorder and
|
|
8234
|
+
// BEFORE `blocks` is rebuilt — so every block's ORIGINAL bytes are
|
|
8235
|
+
// replayed by id and nothing is re-escaped ('~5px' would otherwise come
|
|
8236
|
+
// back '\~5px'). Only the marker and the leading columns are re-stated,
|
|
8237
|
+
// which is exactly §3.8's renumbering and §3.4's colDelta.
|
|
8238
|
+
// One commitRangeEdit inside commitListStructure() is one undo op, which is
|
|
8239
|
+
// §3.4's requirement for one gesture.
|
|
8240
|
+
//
|
|
8241
|
+
// `data-list-start` is a POSITION property, not an item property. It is the
|
|
8242
|
+
// only carrier of "marked's lexer opened a list token here" (§3.8 rule (d))
|
|
8243
|
+
// and serializeBlocks() resets its ordinal counter on it, so a reorder that
|
|
8244
|
+
// changes which item stands FIRST has to carry it across — the mirror image
|
|
8245
|
+
// of duplicateListItems() stripping it from a copy. MEASURED, on
|
|
8246
|
+
// '1. alpha / 2. bravo / 3. charlie' with charlie dragged to the head:
|
|
8247
|
+
// without the transfer the attribute stays on `alpha`, which is now the
|
|
8248
|
+
// span's SECOND block, and the run comes back
|
|
8249
|
+
// '1. charlie / 1. alpha / 2. bravo'.
|
|
8250
|
+
//
|
|
8251
|
+
// SCOPE — and it is exactly the task's title, "within its own run". The
|
|
8252
|
+
// destination must be a slot between the item's own §3.8 RUN SIBLINGS, or
|
|
8253
|
+
// the slot immediately past the last of them. Everything else is a
|
|
8254
|
+
// cross-boundary move: out of the list entirely, into a nested sublist, into
|
|
8255
|
+
// a different run. Task 5 owns that enumeration and the ruling it implements
|
|
8256
|
+
// is already made — refused with a banner in 3.0.0 — so they are refused
|
|
8257
|
+
// here with Task 5's own constant rather than half-implemented.
|
|
8258
|
+
//
|
|
8259
|
+
// S4 Task 6 widened `liEl` to `liEls` — §3.3's operand set, one OR MORE list
|
|
8260
|
+
// items in document order. The whole function is the same commit: the set is
|
|
8261
|
+
// contiguous in block order (resolveGutterOperands()'s gate), and `run` is a
|
|
8262
|
+
// contiguous slice of the document (listRunOf()'s own contract), so the
|
|
8263
|
+
// members occupy CONSECUTIVE slots of `run` and "move them" is one call to
|
|
8264
|
+
// reorderSpanRange() with a count instead of two paths.
|
|
8265
|
+
async function performListItemDrop(liEls, destEl) {
|
|
8266
|
+
const liEl = liEls[0];
|
|
8267
|
+
const lastLiEl = liEls[liEls.length - 1];
|
|
8268
|
+
// §3.4 rule 2's scope, and the span commitListStructure() re-serializes:
|
|
8269
|
+
// the OUTERMOST run reachable from this item plus every descendant of its
|
|
8270
|
+
// members. Not the item's own sibling run — a span that STARTS at indent 2
|
|
8271
|
+
// has no width recorded for depths 0 and 1 and would be emitted with no
|
|
8272
|
+
// indent prefix at all, i.e. committing it would de-nest the whole thing.
|
|
8273
|
+
const run = listRunOf(liEl);
|
|
8274
|
+
const srcIdxs = liEls.map((el) => run.indexOf(el));
|
|
8275
|
+
const srcIdx = srcIdxs[0];
|
|
8276
|
+
if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8277
|
+
// A member outside the grip's own commit span means the set straddles TWO
|
|
8278
|
+
// runs — contiguity in `blocks` does not imply one run, because two
|
|
8279
|
+
// adjacent runs separated by a delimiter change ('- a' / '1. b') are
|
|
8280
|
+
// adjacent blocks with no phantom between them. Same answer
|
|
8281
|
+
// convertBlockViaMenu() gives, through the same message: the commit
|
|
8282
|
+
// re-serializes ONE run's line range and a member outside it would be
|
|
8283
|
+
// rewritten by a range that does not cover it.
|
|
8284
|
+
if (srcIdxs.indexOf(-1) !== -1) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
8285
|
+
// ...and they must be CONSECUTIVE slots of it. Guaranteed today by
|
|
8286
|
+
// spanIsContiguous() plus listRunOf() returning a contiguous slice, and
|
|
8287
|
+
// checked anyway rather than asserted in prose: reorderSpanRange() takes a
|
|
8288
|
+
// `from` and a `count`, so a gap here would silently drag a NON-member
|
|
8289
|
+
// along with the set.
|
|
8290
|
+
for (let k = 1; k < srcIdxs.length; k++) {
|
|
8291
|
+
if (srcIdxs[k] !== srcIdxs[k - 1] + 1) { refuseStructuralListEdit(BATCH_GAP_MESSAGE); return; }
|
|
8292
|
+
}
|
|
8293
|
+
const all = blockDropCandidates();
|
|
8294
|
+
// ── the SECOND home position ───────────────────────────────────────
|
|
8295
|
+
// `before-block <sourceIndex + 1>` is the seam immediately below the item,
|
|
8296
|
+
// which is the same place on screen as its own top edge. For an item WITH
|
|
8297
|
+
// CHILDREN that next block is its own first child, and "before my own
|
|
8298
|
+
// first child" is precisely where it already is; for the run's LAST item
|
|
8299
|
+
// it is the block AFTER the run, which must be read as "already there"
|
|
8300
|
+
// and not as a boundary crossing. Answered before every gate, for Task 3's
|
|
8301
|
+
// reason: a refusal here would be a banner for a gesture in which nothing
|
|
8302
|
+
// went wrong.
|
|
8303
|
+
const afterSrcEl = all[all.indexOf(lastLiEl) + 1] || null;
|
|
8304
|
+
if (destEl ? destEl === afterSrcEl : all[all.length - 1] === lastLiEl) return;
|
|
8305
|
+
// ── §4.3's run-wide gate ───────────────────────────────────────────
|
|
8306
|
+
// 「轉換/建立副本/刪除/拖曳一律在 mutation 前走
|
|
8307
|
+
// listRunSupportsStructuralEdit()」. There is no shared helper — the four
|
|
8308
|
+
// existing call sites each make the call themselves — so this one does
|
|
8309
|
+
// too. Its input is §3.4 rule 2's scope, which is exactly what listRunOf()
|
|
8310
|
+
// returns. Ahead of every OTHER refusal, the convention convertBlockViaMenu()
|
|
8311
|
+
// states: a run that cannot be structurally edited at all must say so,
|
|
8312
|
+
// rather than report whichever narrower objection is asked next.
|
|
8313
|
+
//
|
|
8314
|
+
// `columnOnly` is the honest option, and it is the same argument
|
|
8315
|
+
// insertBlockBelow()'s li path and the rule-2 merge already make: a
|
|
8316
|
+
// reorder rewrites NO item's CONTENT and NO item's LINE COUNT. The only
|
|
8317
|
+
// bytes that move in any member are its marker and its leading columns —
|
|
8318
|
+
// §3.8's renumbering, applied to a replayed bystander as §3.4's colDelta —
|
|
8319
|
+
// which is the criterion listRunSupportsStructuralEdit() documents.
|
|
8320
|
+
// Without it a single hard-wrapped item anywhere in the run would veto
|
|
8321
|
+
// every drag, which on this repo's own CHANGELOG.md is every run (80.6% of
|
|
8322
|
+
// its list items are hard-wrapped).
|
|
8323
|
+
if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
|
|
8324
|
+
refuseStructuralListEdit();
|
|
8325
|
+
return;
|
|
8326
|
+
}
|
|
8327
|
+
// ── the destination must be a slot in the item's OWN §3.8 run ──────
|
|
8328
|
+
// `sibs` is that run: the same-depth members listRunOf() walked out from.
|
|
8329
|
+
// The two legal shapes are "before sibling S" and "past the last
|
|
8330
|
+
// sibling's whole subtree" — the latter being the ONLY way to name the
|
|
8331
|
+
// end of a run that something follows, since the drop target can only ever
|
|
8332
|
+
// name a block, and the block it names there is outside the run.
|
|
8333
|
+
const sibs = runBlocksOf(liEl);
|
|
8334
|
+
const lastSib = sibs[sibs.length - 1];
|
|
8335
|
+
const sibTail = subtreeBlocksAfter(lastSib, Number(lastSib.getAttribute('data-indent')) || 0);
|
|
8336
|
+
const runEndEl = sibTail.length ? sibTail[sibTail.length - 1] : lastSib;
|
|
8337
|
+
const afterRunEl = all[all.indexOf(runEndEl) + 1] || null;
|
|
8338
|
+
let insertAt = -1;
|
|
8339
|
+
if (destEl === null) {
|
|
8340
|
+
// The {mode:'append'} target is "after the document's last block", so it
|
|
8341
|
+
// names the end of this run only when the run ends the document.
|
|
8342
|
+
if (afterRunEl === null) insertAt = run.indexOf(runEndEl) + 1;
|
|
8343
|
+
} else if (sibs.indexOf(destEl) !== -1) {
|
|
8344
|
+
insertAt = run.indexOf(destEl);
|
|
8345
|
+
} else if (destEl === afterRunEl) {
|
|
8346
|
+
insertAt = run.indexOf(runEndEl) + 1;
|
|
8347
|
+
}
|
|
8348
|
+
// All four out-of-run shapes land here: a slot between two non-li, a
|
|
8349
|
+
// DIFFERENT run, the middle of a run of another list type, and the
|
|
8350
|
+
// {mode:'append'} target when a block follows the run. Task 5 gave them
|
|
8351
|
+
// their own message — the remedy is "stay inside your own list", which
|
|
8352
|
+
// neither seam message says.
|
|
8353
|
+
if (insertAt < 0) { refuseStructuralListEdit(BLOCK_MOVE_OUT_OF_RUN_MESSAGE); return; }
|
|
8354
|
+
// `null` is the order-is-unchanged answer — the two home positions above
|
|
8355
|
+
// reach this by another road (an item whose next block is a sibling), and
|
|
8356
|
+
// it stays SILENT for the same reason.
|
|
8357
|
+
const order = reorderSpanRange(run.length, srcIdx, srcIdxs.length, insertAt);
|
|
8358
|
+
if (!order) return;
|
|
8359
|
+
// Captured BEFORE the reorder, and this is the whole reason duplicateListItems()
|
|
8360
|
+
// captures it too: runRangeOfBlocks() reads the first and last members of
|
|
8361
|
+
// the ARRAY, and the reordered span's first member is not the run's first
|
|
8362
|
+
// line.
|
|
8363
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
8364
|
+
if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8365
|
+
const span = order.map((i) => run[i]);
|
|
8366
|
+
// ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
|
|
8367
|
+
//
|
|
8368
|
+
// §4.5: the moved item keeps its own `data-indent` (nothing here writes
|
|
8369
|
+
// one) and 「子項留在原地(依 3.4 位移夾取)」 — the children it leaves
|
|
8370
|
+
// behind are re-anchored by §3.4.
|
|
8371
|
+
//
|
|
8372
|
+
// THE MEASUREMENT THIS IS BUILT ON, and it is the correction of Task 4's.
|
|
8373
|
+
// Task 4 measured that handing `applyIndentClamp()` the REORDERED span
|
|
8374
|
+
// clamps nothing — true, and for a reason that names its own remedy:
|
|
8375
|
+
// rule 2's scope starts after the operated block's NEW index, while the
|
|
8376
|
+
// orphans are left behind at its OLD one. A move IS a REMOVAL at the old
|
|
8377
|
+
// index plus an INSERTION at the new one, so the orphans are the removal's
|
|
8378
|
+
// business and `{ removed: true }` — the mode the ⠿ delete has used since
|
|
8379
|
+
// S1 — is exactly the right question, asked of `run` in DOCUMENT order
|
|
8380
|
+
// (which it still is: nothing has touched the DOM yet). `clampIndents()`
|
|
8381
|
+
// reports no indent for a removed block, so the travelling members' own
|
|
8382
|
+
// depths are never rewritten by this; the reorder below then re-inserts
|
|
8383
|
+
// them at the destination unchanged. NOTHING in indent-clamp.js changed,
|
|
8384
|
+
// no new mode, no §3.4 amendment.
|
|
8385
|
+
//
|
|
8386
|
+
// Two things the sweep settled that prose could not (every legal indent
|
|
8387
|
+
// array of length 2..6 and depth 0..3 × every operand-set size × every
|
|
8388
|
+
// destination the gate above admits = 4067 drops; the pin is in
|
|
8389
|
+
// test/editor-client.test.js):
|
|
8390
|
+
//
|
|
8391
|
+
// 1. §4.5 also says the moved block is 「落點使其非法時夾到合法值」. That
|
|
8392
|
+
// has NO reachable case: 0 of the 4067 leave the moved block's own
|
|
8393
|
+
// indent illegal, because the destination gate above only ever admits
|
|
8394
|
+
// a slot among its own same-depth siblings and the block standing
|
|
8395
|
+
// before such a slot can always parent it. If that gate is ever
|
|
8396
|
+
// widened (3.1.0) this becomes owed — and rule 1 of clampIndents()
|
|
8397
|
+
// already answers it, on the span, at the new index.
|
|
8398
|
+
// 2. The clamp does not answer everything. 14 of the 4067 stay
|
|
8399
|
+
// unanchored, all of them BATCH sets whose last member is SHALLOWER
|
|
8400
|
+
// than the block they land before — the INSERTION half, at the new
|
|
8401
|
+
// index, for which §3.4 has no rule. Those keep Task 4's refusal,
|
|
8402
|
+
// narrowed to them and re-worded with it.
|
|
8403
|
+
//
|
|
8404
|
+
// ORDER MATTERS: compute, judge, and only then write. A refusal that has
|
|
8405
|
+
// already written `data-indent` leaves a half-clamped DOM behind on a
|
|
8406
|
+
// gesture that wrote no bytes — the same screen/model disagreement the
|
|
8407
|
+
// clamp exists to prevent, produced by the clamp itself.
|
|
8408
|
+
const clamp = computeIndentClamp(run, liEls, spanMinIndent(liEls), { removed: true });
|
|
8409
|
+
const clamped = new Map();
|
|
8410
|
+
(clamp || []).forEach((r) => { clamped.set(r.el, r.indent); });
|
|
8411
|
+
if (!spanIndentsAreAnchored(span.map((el) => ({
|
|
8412
|
+
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
8413
|
+
indent: clamped.has(el) ? clamped.get(el)
|
|
8414
|
+
: (Number(el.getAttribute('data-indent')) || 0),
|
|
8415
|
+
})))) {
|
|
8416
|
+
refuseStructuralListEdit(BLOCK_MOVE_ORPHAN_MESSAGE);
|
|
8417
|
+
return;
|
|
8418
|
+
}
|
|
8419
|
+
// S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
|
|
8420
|
+
// measurement to show it. Deleting the line left all 39 S4 scenarios green
|
|
8421
|
+
// — the four T7 ones named after the clamp included — because every one of
|
|
8422
|
+
// them moves a SINGLE item, and for `count === 1` the write can never
|
|
8423
|
+
// change a byte: serializeBlocks() rebuilds its width stack as it walks
|
|
8424
|
+
// (`widths.length = indent + 1` after every block), so an over-deep
|
|
8425
|
+
// `data-indent` is emitted at its anchor's own column, which is the column
|
|
8426
|
+
// the clamp would have written. Re-run over the whole 4067-drop space with
|
|
8427
|
+
// the real serializeBlocks() on both sides: 0 single-item drops differ, 53
|
|
8428
|
+
// BATCH drops differ on a `ul` and 123 on an `ol`. The smallest is indents
|
|
8429
|
+
// 0,0,1,2,1 with {1,2} moved past the run — without the write the orphan
|
|
8430
|
+
// that kept its departed parent's depth is ADOPTED by a block the user
|
|
8431
|
+
// never touched, which is rule 3's own worked failure. Pinned as bytes by
|
|
8432
|
+
// the RV2 runtime scenario and as counts in test/editor-client.test.js.
|
|
8433
|
+
writeIndentClamp(clamp);
|
|
8434
|
+
const sibsAfter = span.filter((el) => sibs.indexOf(el) !== -1);
|
|
8435
|
+
mutateListRun(() => {
|
|
8436
|
+
// `data-list-start` follows the run's HEAD, not the item — see this
|
|
8437
|
+
// function's own header for the measured '1. charlie / 1. alpha' that
|
|
8438
|
+
// dropping this produces. Only the moved item can change which sibling
|
|
8439
|
+
// stands first, so this is the only pair that can need it; a NESTED
|
|
8440
|
+
// run's own list-start belongs to a member nothing moved and is left
|
|
8441
|
+
// exactly where it is.
|
|
8442
|
+
if (sibsAfter[0] !== sibs[0] && sibs[0].getAttribute('data-list-start') === '1') {
|
|
8443
|
+
sibs[0].removeAttribute('data-list-start');
|
|
8444
|
+
sibsAfter[0].setAttribute('data-list-start', '1');
|
|
8445
|
+
}
|
|
8446
|
+
// `insertAt` indexes `run`, which is still in document order at this
|
|
8447
|
+
// point — the DOM has not been touched yet. `insertAt === run.length`
|
|
8448
|
+
// cannot be the moved item's own slot (that is the home position, out
|
|
8449
|
+
// above), so the reference node is safe to read off the run's last
|
|
8450
|
+
// member.
|
|
8451
|
+
const refEl = insertAt < run.length ? run[insertAt] : run[run.length - 1].nextSibling;
|
|
8452
|
+
// In the set's OWN order, each before the same reference node: that
|
|
8453
|
+
// leaves them consecutive and in document order at the destination,
|
|
8454
|
+
// which is what reorderSpanRange() already said the span would be. A
|
|
8455
|
+
// loop that inserted each before the PREVIOUS one would reverse the set.
|
|
8456
|
+
liEls.forEach((el) => { el.parentNode.insertBefore(el, refEl); });
|
|
8457
|
+
});
|
|
8458
|
+
// AFTER the reorder and BEFORE `blocks` is rebuilt, which is the window
|
|
8459
|
+
// bystanderCarryOver() documents. No `mutatedEl`: nothing in this span had
|
|
8460
|
+
// its CONTENT rewritten — the map is what keeps every member byte-
|
|
8461
|
+
// identical to the file, the moved one included.
|
|
8462
|
+
const carryOver = bystanderCarryOver(span);
|
|
8463
|
+
// §3.3's collapse, declared immediately before the render inside
|
|
8464
|
+
// commitListStructure(). declareCollapse()'s own `if (blockSelection)`
|
|
8465
|
+
// guard is what stops this single-block gesture from INVENTING a
|
|
8466
|
+
// selection when none was standing; when one WAS, resolveGutterOperands()
|
|
8467
|
+
// has already collapsed it onto this item, so it has to follow the item
|
|
8468
|
+
// to its new lines.
|
|
8469
|
+
declareCollapse(movedLiRangeAfterReorder(span, liEls, carryOver, range));
|
|
8470
|
+
await commitListStructure(span, null, false,
|
|
8471
|
+
{ presetRange: range, carryOver: carryOver });
|
|
8472
|
+
}
|
|
8473
|
+
|
|
8474
|
+
// The moved item's line range in the run AS IT IS ABOUT TO BE COMMITTED.
|
|
8475
|
+
//
|
|
8476
|
+
// Deliberately NOT runLineOfBlock(), and this is a measurement that
|
|
8477
|
+
// contradicts the obvious reuse: that function derives the run's start line
|
|
8478
|
+
// with runRangeOfBlocks(runEls), which takes the FIRST member of the array —
|
|
8479
|
+
// correct for every existing caller, whose array is in document order, and
|
|
8480
|
+
// wrong here by construction, because the reordered span's first member is
|
|
8481
|
+
// whichever item was just moved to the head. The rest of its arithmetic is
|
|
8482
|
+
// reproduced: `lineMeta` carries one entry per EMITTED line naming the block
|
|
8483
|
+
// that owns it, so a block's range is the run of entries bearing its id —
|
|
8484
|
+
// counted, never assumed to be one, because a hard-wrapped item replays
|
|
8485
|
+
// several.
|
|
8486
|
+
//
|
|
8487
|
+
// S4 Task 6: `liEls` is §3.3's operand set, one or more. The members travel
|
|
8488
|
+
// together and stay consecutive, so their emitted lines are one contiguous
|
|
8489
|
+
// run and the answer is the FIRST line of the first member to the LAST line
|
|
8490
|
+
// of the last — which is exactly §3.3's 「操作結果所涵蓋的行區間」. Taking
|
|
8491
|
+
// the first member's range alone would collapse a three-block drag onto one
|
|
8492
|
+
// tinted block.
|
|
8493
|
+
function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
|
|
8494
|
+
const ids = liEls.map((el) => el.getAttribute('data-block-id'));
|
|
8495
|
+
if (ids.indexOf(null) !== -1) return null;
|
|
8496
|
+
const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
|
|
8497
|
+
let first = -1;
|
|
8498
|
+
let last = -1;
|
|
8499
|
+
lineMeta.forEach((m, i) => {
|
|
8500
|
+
if (ids.indexOf(m.blockId) === -1) return;
|
|
8501
|
+
if (first < 0) first = i;
|
|
8502
|
+
last = i;
|
|
8503
|
+
});
|
|
8504
|
+
if (first < 0) return null;
|
|
8505
|
+
return { startLine: range.startLine + first, endLine: range.startLine + last };
|
|
8506
|
+
}
|
|
8507
|
+
|
|
8508
|
+
// Unconditional teardown of the in-flight ⠿ gesture — called from FIVE
|
|
8509
|
+
// places, exactly mirroring cancelTeDrag()'s own list: the Escape
|
|
8510
|
+
// prologue's first rungs, `pointercancel`, window `blur`, the defensive
|
|
8511
|
+
// clear at the top of the next `pointerdown`, and every re-render path
|
|
8512
|
+
// (rerenderAll() / resolveBurst()). Nothing was mutated, so there is
|
|
8513
|
+
// nothing to revert and no state this is unsafe to be called from — the
|
|
8514
|
+
// null-out is unconditional on `blockDragState` being set and is NOT gated
|
|
8515
|
+
// on `.dragging`, which is the review fix cancelTeDrag() carries too (a
|
|
8516
|
+
// burst resolution landing during the pressed-but-pre-threshold window used
|
|
8517
|
+
// to leave the table's state pointing at a row rerenderAll() was about to
|
|
8518
|
+
// detach).
|
|
8519
|
+
function cancelBlockDrag() {
|
|
8520
|
+
teardownBlockDrag();
|
|
8521
|
+
}
|
|
8522
|
+
|
|
7127
8523
|
document.addEventListener('pointerdown', (e) => {
|
|
7128
8524
|
if (e.button !== 0) return;
|
|
7129
8525
|
if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
|
|
@@ -7134,6 +8530,10 @@
|
|
|
7134
8530
|
// capture) BEFORE starting a new one, so a stuck drag can never survive
|
|
7135
8531
|
// into the next gesture and a fresh pointerdown always starts clean.
|
|
7136
8532
|
if (tePointer) cancelTeDrag();
|
|
8533
|
+
// S4 Task 2: same defensive reasoning again for the ⠿ block drag — a prior
|
|
8534
|
+
// gesture whose pointerup/pointercancel never arrived must not survive into
|
|
8535
|
+
// this one. Nothing was mutated, so this is always a clean no-op teardown.
|
|
8536
|
+
if (blockDragState) cancelBlockDrag();
|
|
7137
8537
|
// S3 Task 4: same defensive reasoning one line up, for the block-selection
|
|
7138
8538
|
// press — and the flag that suppresses a gesture's own trailing click is
|
|
7139
8539
|
// re-armed here so a gesture whose click never arrived cannot swallow the
|
|
@@ -7163,6 +8563,26 @@
|
|
|
7163
8563
|
// still wins" true by construction: a second, competing pointerdown
|
|
7164
8564
|
// listener would have to re-derive the hit test, and would race this
|
|
7165
8565
|
// one's menu dismiss above depending on registration order.
|
|
8566
|
+
// S4 Task 2 (spec §4.5): the ⠿ drag arms HERE, ahead of both selection
|
|
8567
|
+
// entries, and claims the pointer when it owns it. It has to run before
|
|
8568
|
+
// armBlockSelDrag(), whose first line returns for `.ed-handle` (a member
|
|
8569
|
+
// of ED_SEL_GESTURE_CHROME) — which is why a press on the ⠿ recorded no
|
|
8570
|
+
// pointer state at all before this task. beginShiftClickSelection()
|
|
8571
|
+
// returns for the same target, so ordering it above the Shift branch too
|
|
8572
|
+
// changes nothing for any press that is not on a ⠿: armBlockDrag()
|
|
8573
|
+
// returns false for every other target and falls straight through.
|
|
8574
|
+
//
|
|
8575
|
+
// S4 review round (2026-09-01) — RECORDED, because nobody had. It DOES
|
|
8576
|
+
// change one thing for a press that IS on a ⠿: armBlockDrag() reads no
|
|
8577
|
+
// modifier, so Shift / Ctrl / Alt + ⠿ now starts a drag, where before S4
|
|
8578
|
+
// it did nothing at all until release. KEPT, deliberately: the table's
|
|
8579
|
+
// row and column grips take the pointer with no modifier check either
|
|
8580
|
+
// (see the `if (!hit)` branch's else, below), so a ⠿ that behaved
|
|
8581
|
+
// differently would be the surprise; neither §3.3 nor §4.5 gives a
|
|
8582
|
+
// modified ⠿ press a meaning of its own; and a modified press that never
|
|
8583
|
+
// crosses the threshold still opens the menu, so nothing was taken away.
|
|
8584
|
+
// Pinned by the RV5 scenario in test/editor-client-runtime.test.js.
|
|
8585
|
+
if (armBlockDrag(e)) return;
|
|
7166
8586
|
if (e.shiftKey) { beginShiftClickSelection(e); return; }
|
|
7167
8587
|
armBlockSelDrag(e);
|
|
7168
8588
|
return;
|
|
@@ -7224,12 +8644,30 @@
|
|
|
7224
8644
|
updateBlockSelDrag(e);
|
|
7225
8645
|
});
|
|
7226
8646
|
|
|
8647
|
+
// S4 Task 2: the ⠿ block drag's own pointermove. Registered alongside the
|
|
8648
|
+
// other two rather than folded into either: the table drag's listener bails
|
|
8649
|
+
// on `!tePointer` (the state this one runs in) and the selection drag's bails
|
|
8650
|
+
// on `!blockSelDrag` (likewise). updateBlockDrag()'s own first lines are the
|
|
8651
|
+
// symmetric bail.
|
|
8652
|
+
document.addEventListener('pointermove', (e) => {
|
|
8653
|
+
updateBlockDrag(e);
|
|
8654
|
+
});
|
|
8655
|
+
|
|
7227
8656
|
document.addEventListener('pointerup', async (e) => {
|
|
7228
8657
|
// S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
|
|
7229
8658
|
// this file, so the block-selection drag ends on the pointer events, the
|
|
7230
8659
|
// same skeleton the table drag uses. Before the `!tePointer` bail: a
|
|
7231
8660
|
// selection drag is armed precisely when no grip gesture is in flight.
|
|
7232
8661
|
endBlockSelDrag();
|
|
8662
|
+
// S4 Task 2: and the ⠿ block drag's. Same placement reasoning as the line
|
|
8663
|
+
// above — a block drag is armed precisely when no grip gesture is in
|
|
8664
|
+
// flight, so it must be ended before the `!tePointer` bail.
|
|
8665
|
+
// S4 Task 3: AWAITED, now that the drop writes. endBlockDrag()'s own
|
|
8666
|
+
// teardown runs synchronously (an async function body runs to its first
|
|
8667
|
+
// await), so the `tePointer` branch below is not reordered around any
|
|
8668
|
+
// state it reads; what the await buys is that the commit and its
|
|
8669
|
+
// re-render are complete before this handler returns.
|
|
8670
|
+
await endBlockDrag();
|
|
7233
8671
|
if (!tePointer) return;
|
|
7234
8672
|
const st = tePointer;
|
|
7235
8673
|
releaseTeCapture(st);
|
|
@@ -7275,6 +8713,9 @@
|
|
|
7275
8713
|
// actually moved mid-drag, only the indicator line).
|
|
7276
8714
|
document.addEventListener('pointercancel', () => {
|
|
7277
8715
|
cancelTeDrag();
|
|
8716
|
+
// S4 Task 2: the same abort ends a ⠿ block drag. Nothing was mutated (only
|
|
8717
|
+
// the indicator moved), so this is the same unconditional cleanup.
|
|
8718
|
+
cancelBlockDrag();
|
|
7278
8719
|
// S3 Task 4: the same abort ends a block-selection drag. The selection it
|
|
7279
8720
|
// has built so far STANDS (§4.4: nothing but Escape and a plain click
|
|
7280
8721
|
// clears) — what must not survive is the live drag, or the next stray
|
|
@@ -7291,6 +8732,9 @@
|
|
|
7291
8732
|
// when no gesture is in flight, so this is safe to fire on every blur.
|
|
7292
8733
|
window.addEventListener('blur', () => {
|
|
7293
8734
|
cancelTeDrag();
|
|
8735
|
+
// S4 Task 2: same again for the ⠿ block drag — an undeliverable pointerup
|
|
8736
|
+
// is an undeliverable pointerup whichever gesture is holding it.
|
|
8737
|
+
cancelBlockDrag();
|
|
7294
8738
|
// S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
|
|
7295
8739
|
// drag in flight when focus left is exactly as undeliverable-pointerup as
|
|
7296
8740
|
// a row drag's, so it is torn down here too.
|
|
@@ -7937,6 +9381,11 @@
|
|
|
7937
9381
|
// closeGutterMenu() first, so gutterMenuBlockEl is null by then.
|
|
7938
9382
|
if (e.key === 'Escape') {
|
|
7939
9383
|
if (tePointer && tePointer.dragging) { e.preventDefault(); cancelTeDrag(); return; }
|
|
9384
|
+
// S4 Task 2: the ⠿ block drag gets the same first rung, and for the same
|
|
9385
|
+
// reason — Escape-during-a-drag is a distinct gesture from
|
|
9386
|
+
// Escape-reverts-the-burst, and the drag must be the one that answers.
|
|
9387
|
+
// Mutually exclusive with the rung above (one pointer, one gesture).
|
|
9388
|
+
if (blockDragState && blockDragState.dragging) { e.preventDefault(); cancelBlockDrag(); return; }
|
|
7940
9389
|
if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
|
|
7941
9390
|
if (gutterMenuBlockEl || insertMenuBlockEl) {
|
|
7942
9391
|
e.preventDefault();
|
|
@@ -8312,6 +9761,20 @@
|
|
|
8312
9761
|
hideTableGrips();
|
|
8313
9762
|
return;
|
|
8314
9763
|
}
|
|
9764
|
+
// S4 Task 2: and a third, in the same shape, for the ⠿ block drag —
|
|
9765
|
+
// without it this listener would keep repainting the + bubbles and
|
|
9766
|
+
// repositioning the grips onto whatever row the cursor is dragging
|
|
9767
|
+
// across, on top of the drop indicator, every frame of the gesture. Both
|
|
9768
|
+
// are HIDDEN rather than merely left un-recomputed, so anything already
|
|
9769
|
+
// showing from the frame before the threshold was crossed cannot linger
|
|
9770
|
+
// stale. Unlike the table gate above there is no "active grip" to spare:
|
|
9771
|
+
// the drag's own handle is a per-block button that the pointer has
|
|
9772
|
+
// already left, and the indicator is the whole of the feedback.
|
|
9773
|
+
if (blockDragState && blockDragState.dragging) {
|
|
9774
|
+
hideTableInsertBubbles();
|
|
9775
|
+
hideTableGrips();
|
|
9776
|
+
return;
|
|
9777
|
+
}
|
|
8315
9778
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
8316
9779
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
8317
9780
|
});
|