@helping-ai-workflow/md2doc 2.12.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +1362 -59
- package/lib/md2doc.js +99 -26
- package/package.json +1 -1
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);
|
|
@@ -2558,6 +2875,48 @@
|
|
|
2558
2875
|
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
2559
2876
|
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
2560
2877
|
const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
|
|
2878
|
+
// S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
|
|
2879
|
+
//
|
|
2880
|
+
// Task 3 raised all of these with a single provisional constant
|
|
2881
|
+
// (BLOCK_MOVE_LIST_SEAM_MESSAGE, '區塊搬移不能跨越清單邊界') and said THIS task
|
|
2882
|
+
// owned the final wording. They are three different problems with three
|
|
2883
|
+
// OPPOSITE remedies, so one sentence for all of them sends two thirds of
|
|
2884
|
+
// the users to fix the wrong end of their own gesture:
|
|
2885
|
+
// * the SOURCE seam — the block cannot leave where it is, because the two
|
|
2886
|
+
// lists it stands between would join into one loose run. No destination
|
|
2887
|
+
// helps; the remedy is not to move it (or to move something else).
|
|
2888
|
+
// * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
|
|
2889
|
+
// is to aim somewhere else, which is the exact opposite advice.
|
|
2890
|
+
// * OUT OF RUN — a list item may only move among its own §3.8 siblings.
|
|
2891
|
+
// The remedy is to stay inside its own list.
|
|
2892
|
+
// Naming follows the BATCH_*_MESSAGE convention above. None of the three is
|
|
2893
|
+
// a substring of another, of §4.3's own gate message, or of
|
|
2894
|
+
// BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
|
|
2895
|
+
// refusal fired rather than that some banner mentioning 清單 appeared.
|
|
2896
|
+
const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
|
|
2897
|
+
const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
|
|
2898
|
+
const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
|
|
2899
|
+
// S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
|
|
2900
|
+
// of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
|
|
2901
|
+
// removal half of the move through clampIndents()'s `{ removed: true }`),
|
|
2902
|
+
// which answers every SOURCE-side orphan. What no removal-clamp can reach is
|
|
2903
|
+
// the INSERTION half: a BATCH whose last member is SHALLOWER than the block
|
|
2904
|
+
// it lands before leaves that block with no parent chain above it, at the
|
|
2905
|
+
// NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
|
|
2906
|
+
// indent array of length 2..6 and depth 0..3, every operand-set size and
|
|
2907
|
+
// every destination the run gate admits: 4067 drops, 1425 refused by Task
|
|
2908
|
+
// 4's predicate, 14 still unanchored after the clamp — every one of them a
|
|
2909
|
+
// multi-block set breaking at the block immediately after the landed set.
|
|
2910
|
+
// (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
|
|
2911
|
+
//
|
|
2912
|
+
// RE-WORDED with the narrowing, and the wording is the point: Task 4's
|
|
2913
|
+
// 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
|
|
2914
|
+
// was true while the capability was missing and is a lie now that it is not.
|
|
2915
|
+
// What is left is a DESTINATION objection with the same remedy
|
|
2916
|
+
// BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
|
|
2917
|
+
// end of the gesture is at fault. Still not a substring of any other refusal
|
|
2918
|
+
// in this file, which is what lets a test assert WHICH one fired.
|
|
2919
|
+
const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
|
|
2561
2920
|
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
2562
2921
|
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
2563
2922
|
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
@@ -3641,6 +4000,10 @@
|
|
|
3641
4000
|
hideTableGrips();
|
|
3642
4001
|
hideTableEdgeMenu();
|
|
3643
4002
|
cancelTeDrag();
|
|
4003
|
+
// S4 Task 2: and an in-flight ⠿ block drag, for the same reason — this
|
|
4004
|
+
// resolution is about to commit and re-render whichever block it holds an
|
|
4005
|
+
// ordinal into.
|
|
4006
|
+
cancelBlockDrag();
|
|
3644
4007
|
if (!burst) return true;
|
|
3645
4008
|
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
3646
4009
|
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
@@ -6229,8 +6592,9 @@
|
|
|
6229
6592
|
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
6230
6593
|
// user can actually see and aim for. Both grips are overlay elements
|
|
6231
6594
|
// `position: fixed`-appended to document.body — never DOM CHILDREN of a
|
|
6232
|
-
// contenteditable cell, even though the row grip
|
|
6233
|
-
//
|
|
6595
|
+
// contenteditable cell, even though the row grip visually overlaps one
|
|
6596
|
+
// (since §4.2 conflict 2 landed in S4 T1 it sits ENTIRELY inside the first
|
|
6597
|
+
// column, within that cell's own widened left padding; see the geometry
|
|
6234
6598
|
// note in the "Notion-style row/column grip handles" section below). So —
|
|
6235
6599
|
// unlike the old zones, which
|
|
6236
6600
|
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
@@ -6543,20 +6907,46 @@
|
|
|
6543
6907
|
// listener (wired near the bottom of this file) that already drives
|
|
6544
6908
|
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
6545
6909
|
// contract this reuses.
|
|
6546
|
-
//
|
|
6547
|
-
//
|
|
6548
|
-
//
|
|
6549
|
-
//
|
|
6550
|
-
// grip:
|
|
6551
|
-
//
|
|
6552
|
-
//
|
|
6553
|
-
//
|
|
6554
|
-
// row
|
|
6555
|
-
//
|
|
6556
|
-
//
|
|
6557
|
-
//
|
|
6558
|
-
//
|
|
6559
|
-
//
|
|
6910
|
+
// Geometry, per axis (they are NOT mirror images — see below):
|
|
6911
|
+
// COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
|
|
6912
|
+
// its hit rect straddles that border by half its own height on each
|
|
6913
|
+
// side (P0-a).
|
|
6914
|
+
// ROW grip: its LEFT EDGE coincides with the table's LEFT border, so the
|
|
6915
|
+
// whole grip is INSIDE the table. This is spec §4.2 hit-test
|
|
6916
|
+
// conflict 2's ruling, landed in S4 T1.
|
|
6917
|
+
//
|
|
6918
|
+
// Why the row axis had to break the symmetry. The block's own gutter ⠿
|
|
6919
|
+
// hangs outside the content box at [blockLeft-22, blockLeft-4], and a
|
|
6920
|
+
// table block's own table starts AT blockLeft, so a row grip centred on
|
|
6921
|
+
// the table's left border occupied [blockLeft-10, blockLeft+10] and
|
|
6922
|
+
// overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
|
|
6923
|
+
// positioned overlays and the grip is a document.body child at z-index 7,
|
|
6924
|
+
// so the grip won that hit test whenever it was showing — which is
|
|
6925
|
+
// whenever the pointer had recently been over a table cell. MEASURED on
|
|
6926
|
+
// v2.12.0 at 1400x900 with the row grip parked on the HEADER row (the only
|
|
6927
|
+
// row whose 28px-tall grip reaches the ⠿'s own top-20px band):
|
|
6928
|
+
// elementFromPoint() walked across the table block's ⠿ at its vertical
|
|
6929
|
+
// centre answered .ed-handle for x 390..401 and the ROW GRIP for x
|
|
6930
|
+
// 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
|
|
6931
|
+
// a table ROW drag instead of a block move.
|
|
6932
|
+
//
|
|
6933
|
+
// An earlier revision (v2.10.1) already tried the inset and was reverted,
|
|
6934
|
+
// because a 20px grip inside a cell whose left padding is 14px sits on
|
|
6935
|
+
// that cell's TEXT. That is fixed on the CSS side this time rather than by
|
|
6936
|
+
// giving the grip back its outside half: lib/md2doc.js pads the first
|
|
6937
|
+
// column to --ed-te-cell-pad-left (the grip's own width plus the gutter
|
|
6938
|
+
// pair's breathing gap) in EDIT MODE ONLY. The ⠿ ↔ grip clearance that
|
|
6939
|
+
// remains is exactly that same breathing-gap token — see the edit-mode
|
|
6940
|
+
// :root block in lib/md2doc.js, which spells both tokens out; this file
|
|
6941
|
+
// deliberately does not name them, because test/editor-client.test.js
|
|
6942
|
+
// forbids the retired hover-gutter class name as a bare substring and the
|
|
6943
|
+
// token names contain it. Neither edge is written down twice, so the two
|
|
6944
|
+
// cannot drift apart again — drift is what produced this defect (the CSS
|
|
6945
|
+
// comment claiming "an 8px gap" was describing v2.11.0's pair position,
|
|
6946
|
+
// which v2.11.1 replaced).
|
|
6947
|
+
// test/editor-client-runtime.test.js asserts BOTH directions: every pixel
|
|
6948
|
+
// of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
|
|
6949
|
+
// grip's own centre answers the row grip.
|
|
6560
6950
|
// Either way the grip's hit rect DOES overlap the insert bubble's hit
|
|
6561
6951
|
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
6562
6952
|
// own axis). Non-intersection via rect separation is no longer possible
|
|
@@ -6635,12 +7025,16 @@
|
|
|
6635
7025
|
// gripCenter(), which jump straight to the grip's own coordinates) could
|
|
6636
7026
|
// ever land on it; a real mouse gesture could not.
|
|
6637
7027
|
//
|
|
6638
|
-
// Current geometry (
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6641
|
-
//
|
|
6642
|
-
//
|
|
6643
|
-
//
|
|
7028
|
+
// Current geometry (the two axes DIVERGED in S4 T1, §4.2 conflict 2): the
|
|
7029
|
+
// COLUMN grip still straddles the table's TOP border, half above / half
|
|
7030
|
+
// below, so the corridor-crossing bug described above still applies to it
|
|
7031
|
+
// exactly as originally described and pointInColGripZone() still covers
|
|
7032
|
+
// the outside-the-border corridor. The ROW grip is now entirely INSIDE the
|
|
7033
|
+
// table (its left edge on the table's left border), so a pointer
|
|
7034
|
+
// travelling from a cell to it never leaves the table and there is no
|
|
7035
|
+
// corridor to cross — pointInRowGripZone() is the padded grip rect alone.
|
|
7036
|
+
// See its own comment for why the old formula was deleted rather than
|
|
7037
|
+
// left in place to evaluate to the empty set.
|
|
6644
7038
|
//
|
|
6645
7039
|
// Review fix (Important, first pass over-permissive): the first version of
|
|
6646
7040
|
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
@@ -6653,14 +7047,30 @@
|
|
|
6653
7047
|
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
6654
7048
|
// pointInColGripZone() below) instead of the whole table.
|
|
6655
7049
|
//
|
|
6656
|
-
// What each keep-zone covers TODAY
|
|
6657
|
-
//
|
|
6658
|
-
//
|
|
6659
|
-
//
|
|
6660
|
-
//
|
|
6661
|
-
//
|
|
6662
|
-
//
|
|
6663
|
-
// the
|
|
7050
|
+
// What each keep-zone covers TODAY — and the two axes diverged when §4.2
|
|
7051
|
+
// conflict 2 moved the row grip inside the table (S4 T1):
|
|
7052
|
+
// pointInColGripZone() still keeps the ORIGINAL corridor shape: the
|
|
7053
|
+
// union of (the column grip's own padded rect) and (the straight strip
|
|
7054
|
+
// between the grip's own TOP edge and the table's top border, x clamped
|
|
7055
|
+
// to the anchor COLUMN's own horizontal extent, padded). The column
|
|
7056
|
+
// grip still straddles the top border, so that corridor is still real.
|
|
7057
|
+
// pointInRowGripZone() is now the padded grip rect ALONE. Its corridor
|
|
7058
|
+
// was the strip between the grip's own LEFT edge and the table's LEFT
|
|
7059
|
+
// border; with the grip's left edge ON that border the strip is empty,
|
|
7060
|
+
// and §4.6 explicitly warns that leaving the formula in place would
|
|
7061
|
+
// make it (and the two scenarios downstream of it) silently vacuous
|
|
7062
|
+
// rather than red. It is deleted rather than left to read as if it did
|
|
7063
|
+
// something. Nothing is lost: the corridor existed because a pointer
|
|
7064
|
+
// travelling from a cell to a border-straddling grip had to leave the
|
|
7065
|
+
// table on the way, and a grip that is entirely inside the table has no
|
|
7066
|
+
// such approach — the pointer goes cell -> grip, and the
|
|
7067
|
+
// `target.closest('.ed-te-grip-row')` early return at the top of
|
|
7068
|
+
// updateTableEdgeGrips() covers the moment it arrives. The observable
|
|
7069
|
+
// change is that leaving the table on the left now hides the row grip
|
|
7070
|
+
// immediately, at the anchor row's own height as well as at any other
|
|
7071
|
+
// row's; the scenario named below asserts both halves, because "hidden
|
|
7072
|
+
// at another row's height" alone is true of any geometry once the
|
|
7073
|
+
// corridor is gone.
|
|
6664
7074
|
// A pointer outside either grip's own zone is a genuine exit and still
|
|
6665
7075
|
// hides the grip via hideTableGrips(), same as before. Neither fix touches
|
|
6666
7076
|
// either grip's size or z-index, so the click-priority guarantee (bubble
|
|
@@ -6680,15 +7090,17 @@
|
|
|
6680
7090
|
function pointInRowGripZone(x, y) {
|
|
6681
7091
|
if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
|
|
6682
7092
|
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
//
|
|
6686
|
-
//
|
|
6687
|
-
//
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
7093
|
+
// §4.2 衝突 2(S4 T1)之後 row grip 整個落在表格內側,左緣就壓在表格
|
|
7094
|
+
// 左邊界上,所以舊的走廊公式 `x >= gr.left && x <= tableRect.left` 塌成
|
|
7095
|
+
// 空集合。§4.6 已經預告過這件事:留著它不會變紅,只會變成空測。這裡
|
|
7096
|
+
// 直接刪掉,keep-zone 只剩 grip 自己的 padded rect。
|
|
7097
|
+
// 走廊本來只為了一件事存在——指標從儲存格走向一個跨在邊界上的 grip
|
|
7098
|
+
// 時必定會先離開表格;grip 內移之後那條路徑整段都在表格內(先是
|
|
7099
|
+
// 儲存格,接著是 grip 自己,由 updateTableEdgeGrips() 開頭那個
|
|
7100
|
+
// `target.closest('.ed-te-grip-row')` 早退接手),所以沒有走廊可走。
|
|
7101
|
+
// 對使用者可見的差別是:從左側離開表格會立刻收掉 row grip,錨定那一
|
|
7102
|
+
// 列的高度也不例外。
|
|
7103
|
+
return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
|
|
6692
7104
|
}
|
|
6693
7105
|
|
|
6694
7106
|
function pointInColGripZone(x, y) {
|
|
@@ -6717,9 +7129,10 @@
|
|
|
6717
7129
|
function updateTableEdgeGrips(x, y, target) {
|
|
6718
7130
|
// Both grips are `position: fixed` overlays appended to document.body
|
|
6719
7131
|
// (same as the hover-insert bubbles) rather than descendants of the
|
|
6720
|
-
// table —
|
|
6721
|
-
// border
|
|
6722
|
-
//
|
|
7132
|
+
// table — the column grip PAINTS half outside it (straddling the top
|
|
7133
|
+
// border) and the row grip paints inside the first column (§4.2
|
|
7134
|
+
// conflict 2). Either way neither is a table descendant, so the moment
|
|
7135
|
+
// the real pointer crosses from a cell onto
|
|
6723
7136
|
// the grip itself, `target` is the grip and is no
|
|
6724
7137
|
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
6725
7138
|
// Without this guard, that transition would hit the "nothing found"
|
|
@@ -6754,8 +7167,11 @@
|
|
|
6754
7167
|
// Row grip: every row, including the header — the first row of a
|
|
6755
7168
|
// markdown table IS the header, so any row must be draggable to the
|
|
6756
7169
|
// top to become it. ONE position rule for all of them (user acceptance:
|
|
6757
|
-
// 「grip 位置都一樣」) —
|
|
6758
|
-
//
|
|
7170
|
+
// 「grip 位置都一樣」) — its LEFT EDGE on the table's left border, i.e.
|
|
7171
|
+
// entirely inside the table (spec §4.2 hit-test conflict 2, landed
|
|
7172
|
+
// S4 T1 — see the geometry note above buildTableGrip() for the
|
|
7173
|
+
// measurement that forced it), vertically centred on its own row; no
|
|
7174
|
+
// header special case. The
|
|
6759
7175
|
// one exception is a header-only table (no body rows): its single row
|
|
6760
7176
|
// is thead's only row, and dragging it away would empty the thead —
|
|
6761
7177
|
// serializeTable() would degrade it and the user's table would vanish
|
|
@@ -6764,13 +7180,19 @@
|
|
|
6764
7180
|
gripRowTableEl = tableEl;
|
|
6765
7181
|
gripRowEl = rowEl;
|
|
6766
7182
|
const r = rowEl.getBoundingClientRect();
|
|
6767
|
-
// Fallback
|
|
6768
|
-
// (
|
|
6769
|
-
//
|
|
6770
|
-
// `hidden = false` assignment below takes effect.
|
|
6771
|
-
|
|
7183
|
+
// Fallback height matches .ed-te-grip-row's own CSS height exactly
|
|
7184
|
+
// (28) — offsetHeight reads 0 while `hidden` (display: none) is still
|
|
7185
|
+
// true on the FIRST show of a hover session, before the
|
|
7186
|
+
// `hidden = false` assignment below takes effect. There is no width
|
|
7187
|
+
// fallback any more, and that is the point: the left edge is the
|
|
7188
|
+
// table's own border, so the grip's WIDTH is not an input to its
|
|
7189
|
+
// position at all. It used to be (`tableRect.left - gw / 2`), which
|
|
7190
|
+
// meant the CSS width and this literal `20` had to agree for the
|
|
7191
|
+
// gutter clearance to hold — exactly the drift §4.2 conflict 2 is
|
|
7192
|
+
// about. The width now lives in one place only, the
|
|
7193
|
+
// --ed-te-grip-row-w token in lib/md2doc.js.
|
|
6772
7194
|
const gh = rowGrip.offsetHeight || 28;
|
|
6773
|
-
rowGrip.style.left =
|
|
7195
|
+
rowGrip.style.left = tableRect.left + 'px';
|
|
6774
7196
|
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
6775
7197
|
rowGrip.hidden = false;
|
|
6776
7198
|
} else {
|
|
@@ -7124,6 +7546,820 @@
|
|
|
7124
7546
|
currentBurst.history.snap('drag-col');
|
|
7125
7547
|
}
|
|
7126
7548
|
|
|
7549
|
+
// ── S4 Task 2: dragging a BLOCK by its ⠿ handle (spec §4.5) ─────────────
|
|
7550
|
+
// The gesture SKELETON only — threshold, capture, drop-target computation,
|
|
7551
|
+
// the indicator, and every cancellation path. THE DROP IS A NO-OP; Tasks
|
|
7552
|
+
// 3-7 add the commits. §4.5 says to reuse the table row drag's skeleton
|
|
7553
|
+
// above, and this does, including its central invariant:
|
|
7554
|
+
//
|
|
7555
|
+
// NOTHING IN THE DOM IS MUTATED DURING A DRAG. Only the indicator moves.
|
|
7556
|
+
//
|
|
7557
|
+
// That is the whole reason cancelBlockDrag() can be called unconditionally
|
|
7558
|
+
// from Escape / pointercancel / window blur / the next pointerdown /
|
|
7559
|
+
// rerenderAll() / resolveBurst() with nothing to revert — see
|
|
7560
|
+
// cancelTeDrag()'s own comment for the same argument on the row axis. It
|
|
7561
|
+
// also means no `class=""` residue can reach a burst's zero-edit guard (the
|
|
7562
|
+
// row path had to strip one by hand, because it DIMS the dragged <tr>; this
|
|
7563
|
+
// gesture deliberately dims nothing, so the question never arises). Even the
|
|
7564
|
+
// ⠿ menu, which lives as a CHILD of its block and would therefore be a
|
|
7565
|
+
// content mutation to close, is left alone until the gesture is over.
|
|
7566
|
+
// S4 Task 2b: the drag-cursor class, on `document.documentElement`. Named
|
|
7567
|
+
// once so the add, the remove and lib/md2doc.js's rule cannot drift apart.
|
|
7568
|
+
const BLOCK_DRAGGING_CLASS = 'ed-block-dragging';
|
|
7569
|
+
const blockDropIndicator = document.createElement('div');
|
|
7570
|
+
blockDropIndicator.className = 'ed-block-drop-indicator';
|
|
7571
|
+
blockDropIndicator.hidden = true;
|
|
7572
|
+
document.body.appendChild(blockDropIndicator);
|
|
7573
|
+
|
|
7574
|
+
// The drop-target candidates: the document's block sequence, in document
|
|
7575
|
+
// order. MEASURED on this branch (1400x900, a doc mixing paragraphs, a
|
|
7576
|
+
// heading, a list run with a nested item, a same-line-nest PHANTOM, a
|
|
7577
|
+
// fenced code block and a table): every single `.ed-block` is a DIRECT
|
|
7578
|
+
// child of `contentEl` — S1 flattened the <ul>/<ol> containers away — so
|
|
7579
|
+
// allBlockEls() already IS that sequence, with no nesting to flatten and no
|
|
7580
|
+
// second, overlapping y-range sitting inside another candidate's box.
|
|
7581
|
+
// Named rather than inlined because Tasks 3-7 re-resolve against it.
|
|
7582
|
+
function blockDropCandidates() {
|
|
7583
|
+
return allBlockEls();
|
|
7584
|
+
}
|
|
7585
|
+
|
|
7586
|
+
// Nearest block-drop target for `clientY` — a discriminated union:
|
|
7587
|
+
// {mode: 'before-block', blockIndex, y} | {mode: 'append', y}
|
|
7588
|
+
// `blockIndex` is an ORDINAL into blockDropCandidates(); never a
|
|
7589
|
+
// `data-block-id` (blockmap.js renumbers 0..n-1 on every render, so a
|
|
7590
|
+
// captured id names the target's NEIGHBOUR after any commit that changes
|
|
7591
|
+
// the block count) and never a node (a mid-gesture commit detaches them).
|
|
7592
|
+
//
|
|
7593
|
+
// spec §4.6 replaced the table's `{beforeRow, y}` with exactly this shape,
|
|
7594
|
+
// because a null `beforeRow` had been overloaded to mean "append". That
|
|
7595
|
+
// ruling is followed here rather than nearestColDropTarget()'s flat
|
|
7596
|
+
// `{index, x}`, which is the one place it was never applied.
|
|
7597
|
+
//
|
|
7598
|
+
// There is no 'above-header' counterpart: a table's first row IS its
|
|
7599
|
+
// header, so promoting a row to first place needed its own mode; the
|
|
7600
|
+
// document has no such distinguished first block, and `before-block 0`
|
|
7601
|
+
// already says "become the new first block".
|
|
7602
|
+
function nearestBlockDropTarget(clientY) {
|
|
7603
|
+
const els = blockDropCandidates();
|
|
7604
|
+
for (let i = 0; i < els.length; i++) {
|
|
7605
|
+
const r = els[i].getBoundingClientRect();
|
|
7606
|
+
if (clientY < r.top + r.height / 2) {
|
|
7607
|
+
return { mode: 'before-block', blockIndex: i, y: r.top };
|
|
7608
|
+
}
|
|
7609
|
+
}
|
|
7610
|
+
const last = els[els.length - 1];
|
|
7611
|
+
const rect = last ? last.getBoundingClientRect() : contentEl.getBoundingClientRect();
|
|
7612
|
+
return { mode: 'append', y: rect.bottom };
|
|
7613
|
+
}
|
|
7614
|
+
|
|
7615
|
+
// The in-flight ⠿ gesture, or null between gestures — the block-axis
|
|
7616
|
+
// counterpart of `tePointer`. `sourceIndex` is the ORDINAL of the pressed
|
|
7617
|
+
// block AT PRESS TIME, which is the table drop's own rule ("convert the
|
|
7618
|
+
// pressed node to an ordinal first, re-resolve in the live DOM at drop
|
|
7619
|
+
// time"): between the press and the release, resolveGutterOperands()'s
|
|
7620
|
+
// switchAwayFrom() may commit some other dirty burst, re-render, and detach
|
|
7621
|
+
// every node in the document. `dragging` flips true once
|
|
7622
|
+
// TE_DRAG_THRESHOLD_PX is crossed; `dropTarget` is the union above, filled
|
|
7623
|
+
// in by updateBlockDropIndicator() as the pointer moves.
|
|
7624
|
+
let blockDragState = null;
|
|
7625
|
+
|
|
7626
|
+
// Arms the gesture for a press on a ⠿. Returns TRUE when it owns this
|
|
7627
|
+
// pointer, so the `pointerdown` listener below can stop before it reaches
|
|
7628
|
+
// beginShiftClickSelection()/armBlockSelDrag().
|
|
7629
|
+
//
|
|
7630
|
+
// Running FIRST is required and is also free: `.ed-handle` is a member of
|
|
7631
|
+
// ED_SEL_GESTURE_CHROME, so armBlockSelDrag()'s very first line already
|
|
7632
|
+
// returned for exactly this target (and beginShiftClickSelection()'s does
|
|
7633
|
+
// too) — which is why a press on the ⠿ recorded NO pointer state at all
|
|
7634
|
+
// before this task. Nothing about a press anywhere else changes.
|
|
7635
|
+
//
|
|
7636
|
+
// No preventDefault() here, deliberately. wireBlockSelection()'s delegated
|
|
7637
|
+
// `mousedown` preventDefault() on `.ed-handle` is what keeps a dirty
|
|
7638
|
+
// burst's focus intact across a ⠿ press — without it the press blurs the
|
|
7639
|
+
// burst, the blur commits, the commit re-renders, the button is detached
|
|
7640
|
+
// between mousedown and mouseup and the `click` never fires at all (the
|
|
7641
|
+
// swallowed-first-click defect that fix exists for). Cancelling
|
|
7642
|
+
// `pointerdown` additionally suppresses the compatibility `mousedown` in
|
|
7643
|
+
// some engines, i.e. the very event that fix lives on. updateBlockDrag()
|
|
7644
|
+
// does the preventDefault() instead, once the gesture is definitely a drag.
|
|
7645
|
+
function armBlockDrag(e) {
|
|
7646
|
+
const handleEl = e.target && e.target.closest ? e.target.closest('.ed-handle') : null;
|
|
7647
|
+
if (!handleEl) return false;
|
|
7648
|
+
const blockEl = handleEl.closest('.ed-block');
|
|
7649
|
+
if (!blockEl) return false;
|
|
7650
|
+
const index = blockDropCandidates().indexOf(blockEl);
|
|
7651
|
+
if (index < 0) return false;
|
|
7652
|
+
blockDragState = {
|
|
7653
|
+
pointerId: e.pointerId,
|
|
7654
|
+
sourceIndex: index,
|
|
7655
|
+
// Carried for Tasks 3-7's own re-resolution cross-check only; the
|
|
7656
|
+
// ORDINAL above is the identity, for the renumbering reason spelt out
|
|
7657
|
+
// on `blockDragState`.
|
|
7658
|
+
sourceBlockId: blockEl.getAttribute('data-block-id'),
|
|
7659
|
+
startX: e.clientX, startY: e.clientY,
|
|
7660
|
+
dragging: false,
|
|
7661
|
+
dropTarget: null,
|
|
7662
|
+
captureEl: null,
|
|
7663
|
+
};
|
|
7664
|
+
return true;
|
|
7665
|
+
}
|
|
7666
|
+
|
|
7667
|
+
// Repositions the singleton line onto the current drop target. All four
|
|
7668
|
+
// dimensions are written on every move — the node is this gesture's alone
|
|
7669
|
+
// (see the class comment in lib/md2doc.js for why it is not the table's),
|
|
7670
|
+
// but the content column can be resized by a sidebar drag or a window
|
|
7671
|
+
// resize between two frames, so a partially-written box would leave a stale
|
|
7672
|
+
// width behind.
|
|
7673
|
+
function updateBlockDropIndicator(clientY) {
|
|
7674
|
+
const target = nearestBlockDropTarget(clientY);
|
|
7675
|
+
blockDragState.dropTarget = target;
|
|
7676
|
+
const els = blockDropCandidates();
|
|
7677
|
+
const spanEl = target.mode === 'append' ? els[els.length - 1] : els[target.blockIndex];
|
|
7678
|
+
const r = spanEl ? spanEl.getBoundingClientRect() : contentEl.getBoundingClientRect();
|
|
7679
|
+
blockDropIndicator.style.left = r.left + 'px';
|
|
7680
|
+
blockDropIndicator.style.width = r.width + 'px';
|
|
7681
|
+
blockDropIndicator.style.top = (target.y - 1) + 'px';
|
|
7682
|
+
blockDropIndicator.style.height = '3px';
|
|
7683
|
+
}
|
|
7684
|
+
|
|
7685
|
+
// Capture on documentElement at ENGAGE time, not at press time — the S3
|
|
7686
|
+
// selection drag's reasoning (captureBlockSelDrag()) applies verbatim here
|
|
7687
|
+
// and the table's does not. A press on a ⠿ that turns out to be a plain
|
|
7688
|
+
// click must not be interfered with AT ALL: it has to reach `click` and open
|
|
7689
|
+
// the menu. And the pressed node is a per-block button that rerenderAll()
|
|
7690
|
+
// and openRawEditor()'s restore() both re-create, so capturing on it would
|
|
7691
|
+
// pin a node the gesture can outlive.
|
|
7692
|
+
function captureBlockDrag(e) {
|
|
7693
|
+
const el = document.documentElement;
|
|
7694
|
+
if (el && typeof el.setPointerCapture === 'function') {
|
|
7695
|
+
try { el.setPointerCapture(e.pointerId); blockDragState.captureEl = el; }
|
|
7696
|
+
catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
|
|
7697
|
+
}
|
|
7698
|
+
}
|
|
7699
|
+
|
|
7700
|
+
function releaseBlockDragCapture(st) {
|
|
7701
|
+
if (st && st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
|
|
7702
|
+
try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
|
|
7703
|
+
}
|
|
7704
|
+
}
|
|
7705
|
+
|
|
7706
|
+
function updateBlockDrag(e) {
|
|
7707
|
+
if (!blockDragState || e.pointerId !== blockDragState.pointerId) return;
|
|
7708
|
+
if (tePointer) return; // a grip gesture owns this pointer
|
|
7709
|
+
// The browser can simply never deliver a pointerup (released over browser
|
|
7710
|
+
// chrome, over another window). The next move with no button held is the
|
|
7711
|
+
// only signal left that the gesture is over — the same guard
|
|
7712
|
+
// updateBlockSelDrag() carries, for the same reason.
|
|
7713
|
+
// S4 Task 3: endBlockDrag() is async now. Deliberately not awaited here —
|
|
7714
|
+
// `updateBlockDrag` is called from a `pointermove` listener that cannot
|
|
7715
|
+
// await, and this branch is the last-resort recovery for a pointerup the
|
|
7716
|
+
// browser never delivered. The teardown inside it is synchronous either
|
|
7717
|
+
// way; only the drop's own commit trails.
|
|
7718
|
+
if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockDrag(); return; }
|
|
7719
|
+
if (!blockDragState.dragging) {
|
|
7720
|
+
const dx = e.clientX - blockDragState.startX, dy = e.clientY - blockDragState.startY;
|
|
7721
|
+
if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
|
|
7722
|
+
blockDragState.dragging = true;
|
|
7723
|
+
captureBlockDrag(e);
|
|
7724
|
+
blockDropIndicator.hidden = false;
|
|
7725
|
+
// S4 Task 2b (user request, 2026-09-01): the ONLY feedback the source
|
|
7726
|
+
// block gets. `.ed-handle` is `opacity: 0` off-hover, so from the moment
|
|
7727
|
+
// the pointer leaves the block there is nothing under the cursor but the
|
|
7728
|
+
// indicator line. The ruling is a `cursor: grabbing` class on
|
|
7729
|
+
// `document.documentElement` — OUTSIDE `contentEl`, so the "mutate
|
|
7730
|
+
// nothing in the content DOM during a drag" invariant this whole gesture
|
|
7731
|
+
// rests on still holds, and every cancellation path stays unconditional.
|
|
7732
|
+
// The table drag's `ed-te-row-dragging` dim is deliberately NOT the
|
|
7733
|
+
// model: it dims a node INSIDE the content, and the burst's zero-edit
|
|
7734
|
+
// guard compares `contentEl`'s innerHTML, so the residue it leaves had
|
|
7735
|
+
// to be stripped by hand.
|
|
7736
|
+
//
|
|
7737
|
+
// ⚠ CORRECTED (S4 review round, 2026-09-01). The earlier wording here
|
|
7738
|
+
// and in §4.5 said documentElement AVOIDS that `class=""` residue. It
|
|
7739
|
+
// does not. classList.add() on an element with no `class` attribute
|
|
7740
|
+
// CREATES one and remove() only empties its value — measured in the same
|
|
7741
|
+
// Chromium: `<html class="">` after every engaged drag, every time. The
|
|
7742
|
+
// residue is not avoided, it is RELOCATED: `<html>` is a node nothing in
|
|
7743
|
+
// this file compares, `contentEl.innerHTML` is a string the zero-edit
|
|
7744
|
+
// guard reads on every commit. The ruling is unchanged (the cost really
|
|
7745
|
+
// is zero); only its stated reason was wrong.
|
|
7746
|
+
document.documentElement.classList.add(BLOCK_DRAGGING_CLASS);
|
|
7747
|
+
// Nothing else is touched here. The table's hover chrome (insert
|
|
7748
|
+
// bubbles, grips) is hidden by the rAF `mousemove` gate below, which is
|
|
7749
|
+
// where the row drag and the S3 selection drag both do it too; and the
|
|
7750
|
+
// ⠿ / + menus are left alone until endBlockDrag(), because closing one
|
|
7751
|
+
// detaches a node from INSIDE a block and would break the
|
|
7752
|
+
// "nothing is mutated during a drag" invariant this whole gesture rests
|
|
7753
|
+
// on.
|
|
7754
|
+
}
|
|
7755
|
+
e.preventDefault();
|
|
7756
|
+
updateBlockDropIndicator(e.clientY);
|
|
7757
|
+
}
|
|
7758
|
+
|
|
7759
|
+
// Shared teardown. Both exits below go through it, so a future drop added
|
|
7760
|
+
// to endBlockDrag() can never be reached from a cancellation path.
|
|
7761
|
+
function teardownBlockDrag() {
|
|
7762
|
+
if (!blockDragState) return null;
|
|
7763
|
+
const st = blockDragState;
|
|
7764
|
+
blockDragState = null;
|
|
7765
|
+
releaseBlockDragCapture(st);
|
|
7766
|
+
blockDropIndicator.hidden = true;
|
|
7767
|
+
// S4 Task 2b: removed HERE and nowhere else. T2 routed endBlockDrag() and
|
|
7768
|
+
// cancelBlockDrag() through this one function precisely so a later
|
|
7769
|
+
// addition inherits all five abort paths (pointerup, Escape,
|
|
7770
|
+
// pointercancel, window blur, the next pointerdown / every re-render) for
|
|
7771
|
+
// free. Unconditional on `.dragging`: classList.remove() of a class that
|
|
7772
|
+
// was never added is a no-op, and a gesture that ended before the
|
|
7773
|
+
// threshold never added it.
|
|
7774
|
+
document.documentElement.classList.remove(BLOCK_DRAGGING_CLASS);
|
|
7775
|
+
// A gesture that became a drag owns its own trailing `click`: released
|
|
7776
|
+
// outside every block it would reach wireBlockSelection()'s
|
|
7777
|
+
// switchAwayFrom(); released back over the ⠿ it started on it would OPEN
|
|
7778
|
+
// THE MENU the user was not asking for. Same one-shot flag the S3
|
|
7779
|
+
// selection drag sets, re-armed to false by the next pointerdown so a
|
|
7780
|
+
// gesture whose click never arrives cannot swallow a later one.
|
|
7781
|
+
if (st.dragging) blockSelClickSuppressed = true;
|
|
7782
|
+
return st;
|
|
7783
|
+
}
|
|
7784
|
+
|
|
7785
|
+
// The `pointerup` half. S4 Task 3 makes the drop REAL for a single block
|
|
7786
|
+
// that is not a list item; Tasks 4-7 widen it. The teardown above is
|
|
7787
|
+
// synchronous and happens FIRST, so the gesture is already over (indicator
|
|
7788
|
+
// down, capture released, `ed-block-dragging` off <html>) before a single
|
|
7789
|
+
// byte is considered — nothing the commit does can leave drag state behind.
|
|
7790
|
+
//
|
|
7791
|
+
// ASYNC, and the `pointerup` listener awaits it: the drop resolves an open
|
|
7792
|
+
// burst and re-renders, and a fire-and-forget promise would let a test (or
|
|
7793
|
+
// the next gesture) observe the document between the release and the
|
|
7794
|
+
// commit's own /api/render.
|
|
7795
|
+
async function endBlockDrag() {
|
|
7796
|
+
const st = teardownBlockDrag();
|
|
7797
|
+
if (!st || !st.dragging) return;
|
|
7798
|
+
// The menus are closed AFTER the gesture rather than at engage: closing
|
|
7799
|
+
// one detaches a node from inside a block, and mutating nothing during a
|
|
7800
|
+
// drag is what makes every cancellation path above unconditional.
|
|
7801
|
+
closeGutterMenu();
|
|
7802
|
+
closeInsertMenu();
|
|
7803
|
+
await performBlockDrop(st);
|
|
7804
|
+
}
|
|
7805
|
+
|
|
7806
|
+
// ── S4 Task 3: the drop (spec §4.5) ───────────────────────────────────
|
|
7807
|
+
// Everything up to here has been chrome. This is where the ⠿ drag writes
|
|
7808
|
+
// bytes, through the pure commitBlockMove() above: ONE commitRangeEdit over
|
|
7809
|
+
// the min..max span, hence exactly one Ctrl+Z.
|
|
7810
|
+
//
|
|
7811
|
+
// The pressed block is named by ORDINAL, never by node and never by id —
|
|
7812
|
+
// the table drop's own rule. `blockmap.js` renumbers ids 0..n-1 on every
|
|
7813
|
+
// render, so a captured id names the target's NEIGHBOUR after any commit
|
|
7814
|
+
// that changes the block count, and every node in the document is detached
|
|
7815
|
+
// by a re-render. The ordinal is resolved in the LIVE DOM here, before
|
|
7816
|
+
// resolveGutterOperands() is allowed to commit anything.
|
|
7817
|
+
//
|
|
7818
|
+
// The DESTINATION needs the same protection and gets it one step earlier:
|
|
7819
|
+
// its identity is captured BEFORE resolveGutterOperands(), whose
|
|
7820
|
+
// switchAwayFrom() may commit some other block's dirty burst and re-render.
|
|
7821
|
+
// reresolveBlockEl() then proves the block sitting at that start line
|
|
7822
|
+
// afterwards really is the same one, exactly as the source's own re-resolve
|
|
7823
|
+
// does; when it cannot, the gesture is DROPPED with a banner rather than
|
|
7824
|
+
// completed against a block the user did not point at.
|
|
7825
|
+
async function performBlockDrop(st) {
|
|
7826
|
+
const target = st.dropTarget;
|
|
7827
|
+
// ⚠ S4 Task 6 audited this for §3.6 (「靜默不動作是缺陷」) and it is the one
|
|
7828
|
+
// silent exit in this function that NO GESTURE CAN REACH: performBlockDrop()
|
|
7829
|
+
// is called only from endBlockDrag()'s `st.dragging` branch, engaging sets
|
|
7830
|
+
// `dragging` and then calls updateBlockDropIndicator(), which assigns
|
|
7831
|
+
// `dropTarget` UNCONDITIONALLY from nearestBlockDropTarget() — and that
|
|
7832
|
+
// function has no null answer at all, falling through to
|
|
7833
|
+
// `{mode:'append'}` when no block's mid-line is below the pointer. Kept as
|
|
7834
|
+
// a defensive guard, not as a behaviour.
|
|
7835
|
+
if (!target) return; // engaged but never moved onto a target — nothing to do
|
|
7836
|
+
const els = blockDropCandidates();
|
|
7837
|
+
const sourceEl = els[st.sourceIndex];
|
|
7838
|
+
const destEl = target.mode === 'append' ? null : (els[target.blockIndex] || null);
|
|
7839
|
+
if (!sourceEl || (target.mode !== 'append' && !destEl)) {
|
|
7840
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
7841
|
+
return;
|
|
7842
|
+
}
|
|
7843
|
+
const destIdentity = destEl ? captureBlockIdentity(destEl) : null;
|
|
7844
|
+
// ...and the SAME question resolveGutterOperands() asks about the source,
|
|
7845
|
+
// asked about the destination, BEFORE the commit that answers it.
|
|
7846
|
+
//
|
|
7847
|
+
// S4 review round (2026-09-01), MEDIUM: a drop onto a block with an open
|
|
7848
|
+
// DIRTY burst was ALWAYS refused. Measured on
|
|
7849
|
+
// '# Doc\n\nalpha\n\nbravo\n\ncharlie\n' — type ' EDITED' into
|
|
7850
|
+
// `charlie`, drag `alpha` onto charlie's top edge — and the answer was
|
|
7851
|
+
// '文件已更新,請重試這個操作' with the edit committed and the move not.
|
|
7852
|
+
//
|
|
7853
|
+
// It is an ASYMMETRY, not a race, and it could not be anything else:
|
|
7854
|
+
// reresolveBlockEl()'s fingerprint is the block's SOURCE, and the commit
|
|
7855
|
+
// that changed that source is this drop's OWN resolveGutterOperands() →
|
|
7856
|
+
// switchAwayFrom(). The fingerprint is therefore GUARANTEED to miss. The
|
|
7857
|
+
// source side has had reresolveBlockElAfterSelfCommit() for exactly this
|
|
7858
|
+
// since S2; this is its destination-side counterpart, gated the same way
|
|
7859
|
+
// — on ownsOpenSession() read BEFORE the commit, because `currentBurst` is
|
|
7860
|
+
// gone afterwards — so reresolveBlockEl() keeps its fingerprint for
|
|
7861
|
+
// everybody else. Deliberately NOT a widening of that function: S2's own
|
|
7862
|
+
// note says the narrowed variant exists precisely so no other caller loses
|
|
7863
|
+
// the fingerprint.
|
|
7864
|
+
const destSelfSession = destEl ? ownsOpenSession(destEl) : false;
|
|
7865
|
+
// The shared gutter preamble — identity capture, switchAwayFrom(), the
|
|
7866
|
+
// re-resolve after a commit that re-rendered, the no-source-line refusal
|
|
7867
|
+
// and §3.3's membership. Not re-implemented here: 「不得另寫一條」.
|
|
7868
|
+
const operands = await resolveGutterOperands(sourceEl);
|
|
7869
|
+
if (!operands) return;
|
|
7870
|
+
let liveDestEl = destEl;
|
|
7871
|
+
if (destEl && !document.body.contains(destEl)) {
|
|
7872
|
+
liveDestEl = reresolveBlockEl(destIdentity) ||
|
|
7873
|
+
(destSelfSession ? reresolveBlockElAfterSelfCommit(destIdentity) : null);
|
|
7874
|
+
if (!liveDestEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
7875
|
+
}
|
|
7876
|
+
// ── S4 Task 6: THE OPERAND SET (spec §4.5, §3.3, §3.6) ────────────────
|
|
7877
|
+
// §4.5: 「grip 在選取集合內 → 整批搬(3.3 的成員資格規則)」. Up to Task 5
|
|
7878
|
+
// this read `if (operands.batch) return;` — the last two SILENT shapes in
|
|
7879
|
+
// the drag path, which §3.6 calls a defect in so many words
|
|
7880
|
+
// (「靜默不動作是缺陷」). From here down `opEls` is a span of one OR MORE
|
|
7881
|
+
// blocks and every path takes it whole; nothing loops over it, for the
|
|
7882
|
+
// reason convertBlockViaMenu() states — a commit between two members
|
|
7883
|
+
// re-renders and invalidates every id in between.
|
|
7884
|
+
const opEls = operands.els;
|
|
7885
|
+
const opRecs = operands.recs;
|
|
7886
|
+
// ── the FIRST home position, and it is shared by every kind and size ──
|
|
7887
|
+
// A drop that lands where the block already is is a silent byte no-op —
|
|
7888
|
+
// NOT a refusal and NOT a dropped gesture. It has to be answered before
|
|
7889
|
+
// every gate, and MEASURED, because the gates get it wrong on their own:
|
|
7890
|
+
// `before-block <sourceIndex>` — released on the block's own top edge,
|
|
7891
|
+
// which is exactly what the drag's "released where it started" scenario
|
|
7892
|
+
// drives — makes the destination the SOURCE, and the destination's index
|
|
7893
|
+
// in the source-less array below is then -1, i.e. the
|
|
7894
|
+
// '文件已更新,請重試這個操作' banner, for a gesture in which nothing
|
|
7895
|
+
// whatsoever went wrong. Caught by driving a real gesture and reading the
|
|
7896
|
+
// banner back; the byte assertions could not see it, because the file is
|
|
7897
|
+
// untouched either way. For a SET the same question is membership: any
|
|
7898
|
+
// member's top edge is the set's own footprint. The li path below has a
|
|
7899
|
+
// SECOND home position of its own — see performListItemDrop().
|
|
7900
|
+
if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
|
|
7901
|
+
const shape = spanListKinds(opEls);
|
|
7902
|
+
// §3.6's 2026-08-31 ruling, and the EXISTING banner: 「混合 span 一律拒絕」.
|
|
7903
|
+
// A mixed span would need a blank-line rule at the run/non-run seam — the
|
|
7904
|
+
// li half has to stay tight while the non-li half has to keep its
|
|
7905
|
+
// separators, and inside ONE commit range those two demands conflict. §3.6
|
|
7906
|
+
// records that the spec does not have that rule and that inventing it in
|
|
7907
|
+
// the last stage repeats the §3.4 marker-width error, so this refuses with
|
|
7908
|
+
// 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
|
|
7909
|
+
//
|
|
7910
|
+
// DELIBERATELY ABOVE the second home position, which is the one place this
|
|
7911
|
+
// function departs from Task 3's "answer a home position before every
|
|
7912
|
+
// gate" rule, and the departure is the point: a seam objection depends on
|
|
7913
|
+
// where the user AIMED, so refusing a put-it-back gesture with one would
|
|
7914
|
+
// be a banner for a gesture in which nothing went wrong. MIXED is a
|
|
7915
|
+
// property of the SET — it gives the same answer at every destination, so
|
|
7916
|
+
// there is no aim that would have worked and saying so is the honest
|
|
7917
|
+
// outcome. (The drop-on-a-member case is already out, above.)
|
|
7918
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
7919
|
+
// S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
|
|
7920
|
+
// relocated verbatim (markers and ordinals are run-global), so the whole
|
|
7921
|
+
// run is re-serialized in the new span order instead — still ONE
|
|
7922
|
+
// commitRangeEdit, still one Ctrl+Z. `shape.allLi` rather than the first
|
|
7923
|
+
// member's type: a set is all-li or it is mixed, and mixed is already out.
|
|
7924
|
+
// Everything below this line is the non-li move.
|
|
7925
|
+
if (shape.allLi) {
|
|
7926
|
+
await performListItemDrop(opEls, liveDestEl);
|
|
7927
|
+
return;
|
|
7928
|
+
}
|
|
7929
|
+
// ── the SECOND home position, still before every gate ──────────────
|
|
7930
|
+
// `before-block <sourceIndex + 1>` — the seam immediately below, the same
|
|
7931
|
+
// position on screen — is a no-op planBlockMove() answers null for, but
|
|
7932
|
+
// the list-seam gate below would have refused it OUT LOUD first whenever
|
|
7933
|
+
// the block sits between two list items. (The first home position is
|
|
7934
|
+
// answered above, before the li branch, because both kinds share it.)
|
|
7935
|
+
const live = blockDropCandidates();
|
|
7936
|
+
// The set's own two edges. `si` is its FIRST member and `sj` its LAST —
|
|
7937
|
+
// the source seam is what stands OUTSIDE those, and asking `si + 1` for it
|
|
7938
|
+
// would name the set's second member on any set of more than one.
|
|
7939
|
+
const si = live.indexOf(opEls[0]);
|
|
7940
|
+
const sj = live.indexOf(opEls[opEls.length - 1]);
|
|
7941
|
+
const rest = live.filter((el) => opEls.indexOf(el) === -1);
|
|
7942
|
+
const di = liveDestEl ? rest.indexOf(liveDestEl) : rest.length;
|
|
7943
|
+
if (si < 0 || sj < 0 || di < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
7944
|
+
// §3.3's set is contiguous in block order — resolveGutterOperands()'s own
|
|
7945
|
+
// gate — so its members' lines plus the separators between them are ONE
|
|
7946
|
+
// range, which is what makes a batch move the same single commitRangeEdit
|
|
7947
|
+
// (and the same single Ctrl+Z) a one-block move is. See spanMoveRange().
|
|
7948
|
+
const srcRec = spanMoveRange(opRecs);
|
|
7949
|
+
const destRec = liveDestEl ? blockRecOf(liveDestEl) : null;
|
|
7950
|
+
if (!srcRec || (liveDestEl && !destRec)) {
|
|
7951
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
7952
|
+
return;
|
|
7953
|
+
}
|
|
7954
|
+
// A destination that owns no source line has an INVERTED range, and its
|
|
7955
|
+
// startLine names the line its OWNER occupies — inserting there would put
|
|
7956
|
+
// the block inside somebody else's territory.
|
|
7957
|
+
//
|
|
7958
|
+
// ⚠ S4 Task 6 MEASURED that this is REACHABLE, and that Task 3's comment
|
|
7959
|
+
// here ("unreachable while the seam gate below holds") was wrong. It said
|
|
7960
|
+
// so on the grounds that every no-line phantom this model produces is a
|
|
7961
|
+
// li — true, and beside the point: the seam gate refuses a destination
|
|
7962
|
+
// whose BOTH neighbours are li of one run, and a phantom's upper
|
|
7963
|
+
// neighbour is very often not a li at all. Driven as a real gesture on
|
|
7964
|
+
// '# Doc\n\npara\n\n- - b\n- c\n\ntail\n' (blockmap:
|
|
7965
|
+
// heading{1,1} | para{3,3} | li{5,4} PHANTOM | li{5,5} | li{6,6} |
|
|
7966
|
+
// para{8,8}): the phantom has a box of its own (measured top 190,
|
|
7967
|
+
// bottom 215 at 1400x900), so nearestBlockDropTarget() names it like any
|
|
7968
|
+
// other block; `rest[di - 1]` is the PARAGRAPH, so blockMoveSeamRefusal()
|
|
7969
|
+
// answers null; and planBlockMove() answers a REAL plan for it
|
|
7970
|
+
// ('# Doc\n\npara\n\ntail\n\n- - b\n- c\n'), so it is not a home
|
|
7971
|
+
// position either. Dragging `tail` onto that phantom therefore did
|
|
7972
|
+
// nothing, wrote nothing and said nothing — §3.6's 「靜默不動作是缺陷」,
|
|
7973
|
+
// and the third silent shape this task owed on top of the two the plan
|
|
7974
|
+
// named.
|
|
7975
|
+
//
|
|
7976
|
+
// The banner is the DESTINATION seam's, not a fifth constant: the line
|
|
7977
|
+
// this drop names belongs to the phantom's own nested item, so the block
|
|
7978
|
+
// really would land between an item and its sublist, which is exactly
|
|
7979
|
+
// what 無法把區塊放進清單項目之間 says. The remedy it gives (aim
|
|
7980
|
+
// somewhere else) is the correct one.
|
|
7981
|
+
if (destRec && destRec.endLine < destRec.startLine) {
|
|
7982
|
+
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
7983
|
+
return;
|
|
7984
|
+
}
|
|
7985
|
+
// The second home position (see above): `null` means "already there".
|
|
7986
|
+
// Asked BEFORE the seam gate so a put-it-back gesture is silent rather
|
|
7987
|
+
// than refused. planBlockMove() is pure, so asking twice costs nothing.
|
|
7988
|
+
if (!planBlockMove(lines, blocks, srcRec, destRec)) return;
|
|
7989
|
+
// Both seams MEASURED with marked.lexer, not reasoned about:
|
|
7990
|
+
// destination — '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para`
|
|
7991
|
+
// spliced between the two items lexes as list(1 item) | paragraph |
|
|
7992
|
+
// list(1 item): ONE two-item run became TWO one-item runs, and the
|
|
7993
|
+
// blank lines rule 3 emits are §4.3's looseness trap;
|
|
7994
|
+
// source — '# Doc\n\n- a\n\npara\n\n- b\n' with `para` lifted out
|
|
7995
|
+
// leaves '- a\n\n- b\n', which lexes as ONE list, loose === TRUE.
|
|
7996
|
+
// Every item then renders as <p>, serializeBlocks() reports 'P' for
|
|
7997
|
+
// each, and the whole run degrades read-only WITH NO BANNER.
|
|
7998
|
+
// S4 Task 5 NARROWED the destination half of this and deliberately left
|
|
7999
|
+
// the source half wide; blockMoveSeamRefusal()'s own header carries the
|
|
8000
|
+
// full enumeration and the measurement behind every row of it. The short
|
|
8001
|
+
// version: an insertion can only SPLIT, so the destination only has to
|
|
8002
|
+
// refuse a seam INSIDE one run; a removal MERGES, and the model does not
|
|
8003
|
+
// carry the raw bytes (the bullet character, the leading two spaces) that
|
|
8004
|
+
// decide whether two runs about to become adjacent stay two lists.
|
|
8005
|
+
//
|
|
8006
|
+
// The descriptor is built from listRunOf() — §3.4 rule 2's scope — so a
|
|
8007
|
+
// nested child and its parent answer the same run key, which is what
|
|
8008
|
+
// stops "a paragraph dropped between an item and its own sublist" from
|
|
8009
|
+
// reading as a boundary between two separate lists.
|
|
8010
|
+
const seamDesc = (el) => {
|
|
8011
|
+
if (!el || !el.getAttribute || el.getAttribute('data-block-type') !== 'li') return null;
|
|
8012
|
+
const r = listRunOf(el);
|
|
8013
|
+
return { runKey: r.length ? r[0].getAttribute('data-block-id') : null };
|
|
8014
|
+
};
|
|
8015
|
+
const seamRefusal = blockMoveSeamRefusal(
|
|
8016
|
+
{ prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
|
|
8017
|
+
{ prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
|
|
8018
|
+
if (seamRefusal === 'source') {
|
|
8019
|
+
refuseStructuralListEdit(BLOCK_MOVE_SOURCE_SEAM_MESSAGE);
|
|
8020
|
+
return;
|
|
8021
|
+
}
|
|
8022
|
+
if (seamRefusal === 'destination') {
|
|
8023
|
+
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
8024
|
+
return;
|
|
8025
|
+
}
|
|
8026
|
+
const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
|
|
8027
|
+
// commitRangeEdit()'s own unchanged-text answer, or a refused inverted
|
|
8028
|
+
// range. Neither pushed an op, so there is nothing to render or roll back.
|
|
8029
|
+
//
|
|
8030
|
+
// ⚠ S4 Task 6 audited this too, and corrected Task 3's claim that neither
|
|
8031
|
+
// is reachable. The inverted-range half is not (planBlockMove() has
|
|
8032
|
+
// already answered a real plan). The UNCHANGED-TEXT half is: MEASURED on
|
|
8033
|
+
// 'same\n\nsame\n\ntail\n' with the first paragraph moved past the
|
|
8034
|
+
// second, planBlockMove() answers a genuine plan and the text it produces
|
|
8035
|
+
// is the file byte-for-byte, because the two blocks are identical. That
|
|
8036
|
+
// is a real byte no-op — the document after the gesture IS the document
|
|
8037
|
+
// before it — so it stays silent for Task 3's own reason. A banner here
|
|
8038
|
+
// would tell a user that a move they can SEE succeeded had failed.
|
|
8039
|
+
if (!result.op) return;
|
|
8040
|
+
const prevLines = lines;
|
|
8041
|
+
lines = result.lines;
|
|
8042
|
+
// §3.3's collapse, declared IMMEDIATELY before the render and never
|
|
8043
|
+
// earlier: the moved block's range in the NEW document, which is where a
|
|
8044
|
+
// standing selection must end up. declareCollapse()'s own `if
|
|
8045
|
+
// (blockSelection)` guard is what stops this single-block gesture from
|
|
8046
|
+
// INVENTING a selection when none was standing.
|
|
8047
|
+
declareCollapse(result.newRange);
|
|
8048
|
+
const okRender = await safeRerenderAll();
|
|
8049
|
+
if (!okRender) {
|
|
8050
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
8051
|
+
}
|
|
8052
|
+
}
|
|
8053
|
+
|
|
8054
|
+
// ── S4 Task 4: the drop of a LIST ITEM, inside its own run (spec §4.5) ──
|
|
8055
|
+
//
|
|
8056
|
+
// Task 3's drop returned SILENTLY for a li. §3.6 is explicit that
|
|
8057
|
+
// 「靜默不動作是缺陷」, so after this function a li drag either MOVES or
|
|
8058
|
+
// refuses WITH A BANNER; the only silent outcome left is a home position,
|
|
8059
|
+
// which is Task 3's own ruling (a drop where the block already is is a byte
|
|
8060
|
+
// no-op, not a refusal) and is answered in two places here.
|
|
8061
|
+
//
|
|
8062
|
+
// THE SHAPE IS duplicateListItems()'s, and the recon named it as the only
|
|
8063
|
+
// existing path that already expresses a reorder as ONE commit:
|
|
8064
|
+
// * capture `presetRange` BEFORE mutating — runRangeOfBlocks() takes the
|
|
8065
|
+
// FIRST and LAST members of the array it is handed on the written
|
|
8066
|
+
// assumption that the array is in DOCUMENT order, which the reordered
|
|
8067
|
+
// span is not;
|
|
8068
|
+
// * build the REORDERED SPAN ARRAY (serializeBlocks() emits in array
|
|
8069
|
+
// order, and rebuilds its width/counter/type stacks as it walks — the
|
|
8070
|
+
// output is entirely determined by that order);
|
|
8071
|
+
// * mutateListRun() to move the node, with the focusout suppressed;
|
|
8072
|
+
// * commitListStructure(span, …, {presetRange, carryOver}), where
|
|
8073
|
+
// `carryOver` is bystanderCarryOver(span) — built AFTER the reorder and
|
|
8074
|
+
// BEFORE `blocks` is rebuilt — so every block's ORIGINAL bytes are
|
|
8075
|
+
// replayed by id and nothing is re-escaped ('~5px' would otherwise come
|
|
8076
|
+
// back '\~5px'). Only the marker and the leading columns are re-stated,
|
|
8077
|
+
// which is exactly §3.8's renumbering and §3.4's colDelta.
|
|
8078
|
+
// One commitRangeEdit inside commitListStructure() is one undo op, which is
|
|
8079
|
+
// §3.4's requirement for one gesture.
|
|
8080
|
+
//
|
|
8081
|
+
// `data-list-start` is a POSITION property, not an item property. It is the
|
|
8082
|
+
// only carrier of "marked's lexer opened a list token here" (§3.8 rule (d))
|
|
8083
|
+
// and serializeBlocks() resets its ordinal counter on it, so a reorder that
|
|
8084
|
+
// changes which item stands FIRST has to carry it across — the mirror image
|
|
8085
|
+
// of duplicateListItems() stripping it from a copy. MEASURED, on
|
|
8086
|
+
// '1. alpha / 2. bravo / 3. charlie' with charlie dragged to the head:
|
|
8087
|
+
// without the transfer the attribute stays on `alpha`, which is now the
|
|
8088
|
+
// span's SECOND block, and the run comes back
|
|
8089
|
+
// '1. charlie / 1. alpha / 2. bravo'.
|
|
8090
|
+
//
|
|
8091
|
+
// SCOPE — and it is exactly the task's title, "within its own run". The
|
|
8092
|
+
// destination must be a slot between the item's own §3.8 RUN SIBLINGS, or
|
|
8093
|
+
// the slot immediately past the last of them. Everything else is a
|
|
8094
|
+
// cross-boundary move: out of the list entirely, into a nested sublist, into
|
|
8095
|
+
// a different run. Task 5 owns that enumeration and the ruling it implements
|
|
8096
|
+
// is already made — refused with a banner in 3.0.0 — so they are refused
|
|
8097
|
+
// here with Task 5's own constant rather than half-implemented.
|
|
8098
|
+
//
|
|
8099
|
+
// S4 Task 6 widened `liEl` to `liEls` — §3.3's operand set, one OR MORE list
|
|
8100
|
+
// items in document order. The whole function is the same commit: the set is
|
|
8101
|
+
// contiguous in block order (resolveGutterOperands()'s gate), and `run` is a
|
|
8102
|
+
// contiguous slice of the document (listRunOf()'s own contract), so the
|
|
8103
|
+
// members occupy CONSECUTIVE slots of `run` and "move them" is one call to
|
|
8104
|
+
// reorderSpanRange() with a count instead of two paths.
|
|
8105
|
+
async function performListItemDrop(liEls, destEl) {
|
|
8106
|
+
const liEl = liEls[0];
|
|
8107
|
+
const lastLiEl = liEls[liEls.length - 1];
|
|
8108
|
+
// §3.4 rule 2's scope, and the span commitListStructure() re-serializes:
|
|
8109
|
+
// the OUTERMOST run reachable from this item plus every descendant of its
|
|
8110
|
+
// members. Not the item's own sibling run — a span that STARTS at indent 2
|
|
8111
|
+
// has no width recorded for depths 0 and 1 and would be emitted with no
|
|
8112
|
+
// indent prefix at all, i.e. committing it would de-nest the whole thing.
|
|
8113
|
+
const run = listRunOf(liEl);
|
|
8114
|
+
const srcIdxs = liEls.map((el) => run.indexOf(el));
|
|
8115
|
+
const srcIdx = srcIdxs[0];
|
|
8116
|
+
if (srcIdx < 0) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8117
|
+
// A member outside the grip's own commit span means the set straddles TWO
|
|
8118
|
+
// runs — contiguity in `blocks` does not imply one run, because two
|
|
8119
|
+
// adjacent runs separated by a delimiter change ('- a' / '1. b') are
|
|
8120
|
+
// adjacent blocks with no phantom between them. Same answer
|
|
8121
|
+
// convertBlockViaMenu() gives, through the same message: the commit
|
|
8122
|
+
// re-serializes ONE run's line range and a member outside it would be
|
|
8123
|
+
// rewritten by a range that does not cover it.
|
|
8124
|
+
if (srcIdxs.indexOf(-1) !== -1) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
8125
|
+
// ...and they must be CONSECUTIVE slots of it. Guaranteed today by
|
|
8126
|
+
// spanIsContiguous() plus listRunOf() returning a contiguous slice, and
|
|
8127
|
+
// checked anyway rather than asserted in prose: reorderSpanRange() takes a
|
|
8128
|
+
// `from` and a `count`, so a gap here would silently drag a NON-member
|
|
8129
|
+
// along with the set.
|
|
8130
|
+
for (let k = 1; k < srcIdxs.length; k++) {
|
|
8131
|
+
if (srcIdxs[k] !== srcIdxs[k - 1] + 1) { refuseStructuralListEdit(BATCH_GAP_MESSAGE); return; }
|
|
8132
|
+
}
|
|
8133
|
+
const all = blockDropCandidates();
|
|
8134
|
+
// ── the SECOND home position ───────────────────────────────────────
|
|
8135
|
+
// `before-block <sourceIndex + 1>` is the seam immediately below the item,
|
|
8136
|
+
// which is the same place on screen as its own top edge. For an item WITH
|
|
8137
|
+
// CHILDREN that next block is its own first child, and "before my own
|
|
8138
|
+
// first child" is precisely where it already is; for the run's LAST item
|
|
8139
|
+
// it is the block AFTER the run, which must be read as "already there"
|
|
8140
|
+
// and not as a boundary crossing. Answered before every gate, for Task 3's
|
|
8141
|
+
// reason: a refusal here would be a banner for a gesture in which nothing
|
|
8142
|
+
// went wrong.
|
|
8143
|
+
const afterSrcEl = all[all.indexOf(lastLiEl) + 1] || null;
|
|
8144
|
+
if (destEl ? destEl === afterSrcEl : all[all.length - 1] === lastLiEl) return;
|
|
8145
|
+
// ── §4.3's run-wide gate ───────────────────────────────────────────
|
|
8146
|
+
// 「轉換/建立副本/刪除/拖曳一律在 mutation 前走
|
|
8147
|
+
// listRunSupportsStructuralEdit()」. There is no shared helper — the four
|
|
8148
|
+
// existing call sites each make the call themselves — so this one does
|
|
8149
|
+
// too. Its input is §3.4 rule 2's scope, which is exactly what listRunOf()
|
|
8150
|
+
// returns. Ahead of every OTHER refusal, the convention convertBlockViaMenu()
|
|
8151
|
+
// states: a run that cannot be structurally edited at all must say so,
|
|
8152
|
+
// rather than report whichever narrower objection is asked next.
|
|
8153
|
+
//
|
|
8154
|
+
// `columnOnly` is the honest option, and it is the same argument
|
|
8155
|
+
// insertBlockBelow()'s li path and the rule-2 merge already make: a
|
|
8156
|
+
// reorder rewrites NO item's CONTENT and NO item's LINE COUNT. The only
|
|
8157
|
+
// bytes that move in any member are its marker and its leading columns —
|
|
8158
|
+
// §3.8's renumbering, applied to a replayed bystander as §3.4's colDelta —
|
|
8159
|
+
// which is the criterion listRunSupportsStructuralEdit() documents.
|
|
8160
|
+
// Without it a single hard-wrapped item anywhere in the run would veto
|
|
8161
|
+
// every drag, which on this repo's own CHANGELOG.md is every run (80.6% of
|
|
8162
|
+
// its list items are hard-wrapped).
|
|
8163
|
+
if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) {
|
|
8164
|
+
refuseStructuralListEdit();
|
|
8165
|
+
return;
|
|
8166
|
+
}
|
|
8167
|
+
// ── the destination must be a slot in the item's OWN §3.8 run ──────
|
|
8168
|
+
// `sibs` is that run: the same-depth members listRunOf() walked out from.
|
|
8169
|
+
// The two legal shapes are "before sibling S" and "past the last
|
|
8170
|
+
// sibling's whole subtree" — the latter being the ONLY way to name the
|
|
8171
|
+
// end of a run that something follows, since the drop target can only ever
|
|
8172
|
+
// name a block, and the block it names there is outside the run.
|
|
8173
|
+
const sibs = runBlocksOf(liEl);
|
|
8174
|
+
const lastSib = sibs[sibs.length - 1];
|
|
8175
|
+
const sibTail = subtreeBlocksAfter(lastSib, Number(lastSib.getAttribute('data-indent')) || 0);
|
|
8176
|
+
const runEndEl = sibTail.length ? sibTail[sibTail.length - 1] : lastSib;
|
|
8177
|
+
const afterRunEl = all[all.indexOf(runEndEl) + 1] || null;
|
|
8178
|
+
let insertAt = -1;
|
|
8179
|
+
if (destEl === null) {
|
|
8180
|
+
// The {mode:'append'} target is "after the document's last block", so it
|
|
8181
|
+
// names the end of this run only when the run ends the document.
|
|
8182
|
+
if (afterRunEl === null) insertAt = run.indexOf(runEndEl) + 1;
|
|
8183
|
+
} else if (sibs.indexOf(destEl) !== -1) {
|
|
8184
|
+
insertAt = run.indexOf(destEl);
|
|
8185
|
+
} else if (destEl === afterRunEl) {
|
|
8186
|
+
insertAt = run.indexOf(runEndEl) + 1;
|
|
8187
|
+
}
|
|
8188
|
+
// All four out-of-run shapes land here: a slot between two non-li, a
|
|
8189
|
+
// DIFFERENT run, the middle of a run of another list type, and the
|
|
8190
|
+
// {mode:'append'} target when a block follows the run. Task 5 gave them
|
|
8191
|
+
// their own message — the remedy is "stay inside your own list", which
|
|
8192
|
+
// neither seam message says.
|
|
8193
|
+
if (insertAt < 0) { refuseStructuralListEdit(BLOCK_MOVE_OUT_OF_RUN_MESSAGE); return; }
|
|
8194
|
+
// `null` is the order-is-unchanged answer — the two home positions above
|
|
8195
|
+
// reach this by another road (an item whose next block is a sibling), and
|
|
8196
|
+
// it stays SILENT for the same reason.
|
|
8197
|
+
const order = reorderSpanRange(run.length, srcIdx, srcIdxs.length, insertAt);
|
|
8198
|
+
if (!order) return;
|
|
8199
|
+
// Captured BEFORE the reorder, and this is the whole reason duplicateListItems()
|
|
8200
|
+
// captures it too: runRangeOfBlocks() reads the first and last members of
|
|
8201
|
+
// the ARRAY, and the reordered span's first member is not the run's first
|
|
8202
|
+
// line.
|
|
8203
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
8204
|
+
if (!range) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
8205
|
+
const span = order.map((i) => run[i]);
|
|
8206
|
+
// ── THE SEAM TASK 7 OWNS — §4.5's indent clamp ─────────────────────
|
|
8207
|
+
//
|
|
8208
|
+
// §4.5: the moved item keeps its own `data-indent` (nothing here writes
|
|
8209
|
+
// one) and 「子項留在原地(依 3.4 位移夾取)」 — the children it leaves
|
|
8210
|
+
// behind are re-anchored by §3.4.
|
|
8211
|
+
//
|
|
8212
|
+
// THE MEASUREMENT THIS IS BUILT ON, and it is the correction of Task 4's.
|
|
8213
|
+
// Task 4 measured that handing `applyIndentClamp()` the REORDERED span
|
|
8214
|
+
// clamps nothing — true, and for a reason that names its own remedy:
|
|
8215
|
+
// rule 2's scope starts after the operated block's NEW index, while the
|
|
8216
|
+
// orphans are left behind at its OLD one. A move IS a REMOVAL at the old
|
|
8217
|
+
// index plus an INSERTION at the new one, so the orphans are the removal's
|
|
8218
|
+
// business and `{ removed: true }` — the mode the ⠿ delete has used since
|
|
8219
|
+
// S1 — is exactly the right question, asked of `run` in DOCUMENT order
|
|
8220
|
+
// (which it still is: nothing has touched the DOM yet). `clampIndents()`
|
|
8221
|
+
// reports no indent for a removed block, so the travelling members' own
|
|
8222
|
+
// depths are never rewritten by this; the reorder below then re-inserts
|
|
8223
|
+
// them at the destination unchanged. NOTHING in indent-clamp.js changed,
|
|
8224
|
+
// no new mode, no §3.4 amendment.
|
|
8225
|
+
//
|
|
8226
|
+
// Two things the sweep settled that prose could not (every legal indent
|
|
8227
|
+
// array of length 2..6 and depth 0..3 × every operand-set size × every
|
|
8228
|
+
// destination the gate above admits = 4067 drops; the pin is in
|
|
8229
|
+
// test/editor-client.test.js):
|
|
8230
|
+
//
|
|
8231
|
+
// 1. §4.5 also says the moved block is 「落點使其非法時夾到合法值」. That
|
|
8232
|
+
// has NO reachable case: 0 of the 4067 leave the moved block's own
|
|
8233
|
+
// indent illegal, because the destination gate above only ever admits
|
|
8234
|
+
// a slot among its own same-depth siblings and the block standing
|
|
8235
|
+
// before such a slot can always parent it. If that gate is ever
|
|
8236
|
+
// widened (3.1.0) this becomes owed — and rule 1 of clampIndents()
|
|
8237
|
+
// already answers it, on the span, at the new index.
|
|
8238
|
+
// 2. The clamp does not answer everything. 14 of the 4067 stay
|
|
8239
|
+
// unanchored, all of them BATCH sets whose last member is SHALLOWER
|
|
8240
|
+
// than the block they land before — the INSERTION half, at the new
|
|
8241
|
+
// index, for which §3.4 has no rule. Those keep Task 4's refusal,
|
|
8242
|
+
// narrowed to them and re-worded with it.
|
|
8243
|
+
//
|
|
8244
|
+
// ORDER MATTERS: compute, judge, and only then write. A refusal that has
|
|
8245
|
+
// already written `data-indent` leaves a half-clamped DOM behind on a
|
|
8246
|
+
// gesture that wrote no bytes — the same screen/model disagreement the
|
|
8247
|
+
// clamp exists to prevent, produced by the clamp itself.
|
|
8248
|
+
const clamp = computeIndentClamp(run, liEls, spanMinIndent(liEls), { removed: true });
|
|
8249
|
+
const clamped = new Map();
|
|
8250
|
+
(clamp || []).forEach((r) => { clamped.set(r.el, r.indent); });
|
|
8251
|
+
if (!spanIndentsAreAnchored(span.map((el) => ({
|
|
8252
|
+
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
8253
|
+
indent: clamped.has(el) ? clamped.get(el)
|
|
8254
|
+
: (Number(el.getAttribute('data-indent')) || 0),
|
|
8255
|
+
})))) {
|
|
8256
|
+
refuseStructuralListEdit(BLOCK_MOVE_ORPHAN_MESSAGE);
|
|
8257
|
+
return;
|
|
8258
|
+
}
|
|
8259
|
+
// S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
|
|
8260
|
+
// measurement to show it. Deleting the line left all 39 S4 scenarios green
|
|
8261
|
+
// — the four T7 ones named after the clamp included — because every one of
|
|
8262
|
+
// them moves a SINGLE item, and for `count === 1` the write can never
|
|
8263
|
+
// change a byte: serializeBlocks() rebuilds its width stack as it walks
|
|
8264
|
+
// (`widths.length = indent + 1` after every block), so an over-deep
|
|
8265
|
+
// `data-indent` is emitted at its anchor's own column, which is the column
|
|
8266
|
+
// the clamp would have written. Re-run over the whole 4067-drop space with
|
|
8267
|
+
// the real serializeBlocks() on both sides: 0 single-item drops differ, 53
|
|
8268
|
+
// BATCH drops differ on a `ul` and 123 on an `ol`. The smallest is indents
|
|
8269
|
+
// 0,0,1,2,1 with {1,2} moved past the run — without the write the orphan
|
|
8270
|
+
// that kept its departed parent's depth is ADOPTED by a block the user
|
|
8271
|
+
// never touched, which is rule 3's own worked failure. Pinned as bytes by
|
|
8272
|
+
// the RV2 runtime scenario and as counts in test/editor-client.test.js.
|
|
8273
|
+
writeIndentClamp(clamp);
|
|
8274
|
+
const sibsAfter = span.filter((el) => sibs.indexOf(el) !== -1);
|
|
8275
|
+
mutateListRun(() => {
|
|
8276
|
+
// `data-list-start` follows the run's HEAD, not the item — see this
|
|
8277
|
+
// function's own header for the measured '1. charlie / 1. alpha' that
|
|
8278
|
+
// dropping this produces. Only the moved item can change which sibling
|
|
8279
|
+
// stands first, so this is the only pair that can need it; a NESTED
|
|
8280
|
+
// run's own list-start belongs to a member nothing moved and is left
|
|
8281
|
+
// exactly where it is.
|
|
8282
|
+
if (sibsAfter[0] !== sibs[0] && sibs[0].getAttribute('data-list-start') === '1') {
|
|
8283
|
+
sibs[0].removeAttribute('data-list-start');
|
|
8284
|
+
sibsAfter[0].setAttribute('data-list-start', '1');
|
|
8285
|
+
}
|
|
8286
|
+
// `insertAt` indexes `run`, which is still in document order at this
|
|
8287
|
+
// point — the DOM has not been touched yet. `insertAt === run.length`
|
|
8288
|
+
// cannot be the moved item's own slot (that is the home position, out
|
|
8289
|
+
// above), so the reference node is safe to read off the run's last
|
|
8290
|
+
// member.
|
|
8291
|
+
const refEl = insertAt < run.length ? run[insertAt] : run[run.length - 1].nextSibling;
|
|
8292
|
+
// In the set's OWN order, each before the same reference node: that
|
|
8293
|
+
// leaves them consecutive and in document order at the destination,
|
|
8294
|
+
// which is what reorderSpanRange() already said the span would be. A
|
|
8295
|
+
// loop that inserted each before the PREVIOUS one would reverse the set.
|
|
8296
|
+
liEls.forEach((el) => { el.parentNode.insertBefore(el, refEl); });
|
|
8297
|
+
});
|
|
8298
|
+
// AFTER the reorder and BEFORE `blocks` is rebuilt, which is the window
|
|
8299
|
+
// bystanderCarryOver() documents. No `mutatedEl`: nothing in this span had
|
|
8300
|
+
// its CONTENT rewritten — the map is what keeps every member byte-
|
|
8301
|
+
// identical to the file, the moved one included.
|
|
8302
|
+
const carryOver = bystanderCarryOver(span);
|
|
8303
|
+
// §3.3's collapse, declared immediately before the render inside
|
|
8304
|
+
// commitListStructure(). declareCollapse()'s own `if (blockSelection)`
|
|
8305
|
+
// guard is what stops this single-block gesture from INVENTING a
|
|
8306
|
+
// selection when none was standing; when one WAS, resolveGutterOperands()
|
|
8307
|
+
// has already collapsed it onto this item, so it has to follow the item
|
|
8308
|
+
// to its new lines.
|
|
8309
|
+
declareCollapse(movedLiRangeAfterReorder(span, liEls, carryOver, range));
|
|
8310
|
+
await commitListStructure(span, null, false,
|
|
8311
|
+
{ presetRange: range, carryOver: carryOver });
|
|
8312
|
+
}
|
|
8313
|
+
|
|
8314
|
+
// The moved item's line range in the run AS IT IS ABOUT TO BE COMMITTED.
|
|
8315
|
+
//
|
|
8316
|
+
// Deliberately NOT runLineOfBlock(), and this is a measurement that
|
|
8317
|
+
// contradicts the obvious reuse: that function derives the run's start line
|
|
8318
|
+
// with runRangeOfBlocks(runEls), which takes the FIRST member of the array —
|
|
8319
|
+
// correct for every existing caller, whose array is in document order, and
|
|
8320
|
+
// wrong here by construction, because the reordered span's first member is
|
|
8321
|
+
// whichever item was just moved to the head. The rest of its arithmetic is
|
|
8322
|
+
// reproduced: `lineMeta` carries one entry per EMITTED line naming the block
|
|
8323
|
+
// that owns it, so a block's range is the run of entries bearing its id —
|
|
8324
|
+
// counted, never assumed to be one, because a hard-wrapped item replays
|
|
8325
|
+
// several.
|
|
8326
|
+
//
|
|
8327
|
+
// S4 Task 6: `liEls` is §3.3's operand set, one or more. The members travel
|
|
8328
|
+
// together and stay consecutive, so their emitted lines are one contiguous
|
|
8329
|
+
// run and the answer is the FIRST line of the first member to the LAST line
|
|
8330
|
+
// of the last — which is exactly §3.3's 「操作結果所涵蓋的行區間」. Taking
|
|
8331
|
+
// the first member's range alone would collapse a three-block drag onto one
|
|
8332
|
+
// tinted block.
|
|
8333
|
+
function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
|
|
8334
|
+
const ids = liEls.map((el) => el.getAttribute('data-block-id'));
|
|
8335
|
+
if (ids.indexOf(null) !== -1) return null;
|
|
8336
|
+
const { lineMeta } = listMd.serializeBlocks(span, { carryOver: carryOver || null });
|
|
8337
|
+
let first = -1;
|
|
8338
|
+
let last = -1;
|
|
8339
|
+
lineMeta.forEach((m, i) => {
|
|
8340
|
+
if (ids.indexOf(m.blockId) === -1) return;
|
|
8341
|
+
if (first < 0) first = i;
|
|
8342
|
+
last = i;
|
|
8343
|
+
});
|
|
8344
|
+
if (first < 0) return null;
|
|
8345
|
+
return { startLine: range.startLine + first, endLine: range.startLine + last };
|
|
8346
|
+
}
|
|
8347
|
+
|
|
8348
|
+
// Unconditional teardown of the in-flight ⠿ gesture — called from FIVE
|
|
8349
|
+
// places, exactly mirroring cancelTeDrag()'s own list: the Escape
|
|
8350
|
+
// prologue's first rungs, `pointercancel`, window `blur`, the defensive
|
|
8351
|
+
// clear at the top of the next `pointerdown`, and every re-render path
|
|
8352
|
+
// (rerenderAll() / resolveBurst()). Nothing was mutated, so there is
|
|
8353
|
+
// nothing to revert and no state this is unsafe to be called from — the
|
|
8354
|
+
// null-out is unconditional on `blockDragState` being set and is NOT gated
|
|
8355
|
+
// on `.dragging`, which is the review fix cancelTeDrag() carries too (a
|
|
8356
|
+
// burst resolution landing during the pressed-but-pre-threshold window used
|
|
8357
|
+
// to leave the table's state pointing at a row rerenderAll() was about to
|
|
8358
|
+
// detach).
|
|
8359
|
+
function cancelBlockDrag() {
|
|
8360
|
+
teardownBlockDrag();
|
|
8361
|
+
}
|
|
8362
|
+
|
|
7127
8363
|
document.addEventListener('pointerdown', (e) => {
|
|
7128
8364
|
if (e.button !== 0) return;
|
|
7129
8365
|
if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
|
|
@@ -7134,6 +8370,10 @@
|
|
|
7134
8370
|
// capture) BEFORE starting a new one, so a stuck drag can never survive
|
|
7135
8371
|
// into the next gesture and a fresh pointerdown always starts clean.
|
|
7136
8372
|
if (tePointer) cancelTeDrag();
|
|
8373
|
+
// S4 Task 2: same defensive reasoning again for the ⠿ block drag — a prior
|
|
8374
|
+
// gesture whose pointerup/pointercancel never arrived must not survive into
|
|
8375
|
+
// this one. Nothing was mutated, so this is always a clean no-op teardown.
|
|
8376
|
+
if (blockDragState) cancelBlockDrag();
|
|
7137
8377
|
// S3 Task 4: same defensive reasoning one line up, for the block-selection
|
|
7138
8378
|
// press — and the flag that suppresses a gesture's own trailing click is
|
|
7139
8379
|
// re-armed here so a gesture whose click never arrived cannot swallow the
|
|
@@ -7163,6 +8403,26 @@
|
|
|
7163
8403
|
// still wins" true by construction: a second, competing pointerdown
|
|
7164
8404
|
// listener would have to re-derive the hit test, and would race this
|
|
7165
8405
|
// one's menu dismiss above depending on registration order.
|
|
8406
|
+
// S4 Task 2 (spec §4.5): the ⠿ drag arms HERE, ahead of both selection
|
|
8407
|
+
// entries, and claims the pointer when it owns it. It has to run before
|
|
8408
|
+
// armBlockSelDrag(), whose first line returns for `.ed-handle` (a member
|
|
8409
|
+
// of ED_SEL_GESTURE_CHROME) — which is why a press on the ⠿ recorded no
|
|
8410
|
+
// pointer state at all before this task. beginShiftClickSelection()
|
|
8411
|
+
// returns for the same target, so ordering it above the Shift branch too
|
|
8412
|
+
// changes nothing for any press that is not on a ⠿: armBlockDrag()
|
|
8413
|
+
// returns false for every other target and falls straight through.
|
|
8414
|
+
//
|
|
8415
|
+
// S4 review round (2026-09-01) — RECORDED, because nobody had. It DOES
|
|
8416
|
+
// change one thing for a press that IS on a ⠿: armBlockDrag() reads no
|
|
8417
|
+
// modifier, so Shift / Ctrl / Alt + ⠿ now starts a drag, where before S4
|
|
8418
|
+
// it did nothing at all until release. KEPT, deliberately: the table's
|
|
8419
|
+
// row and column grips take the pointer with no modifier check either
|
|
8420
|
+
// (see the `if (!hit)` branch's else, below), so a ⠿ that behaved
|
|
8421
|
+
// differently would be the surprise; neither §3.3 nor §4.5 gives a
|
|
8422
|
+
// modified ⠿ press a meaning of its own; and a modified press that never
|
|
8423
|
+
// crosses the threshold still opens the menu, so nothing was taken away.
|
|
8424
|
+
// Pinned by the RV5 scenario in test/editor-client-runtime.test.js.
|
|
8425
|
+
if (armBlockDrag(e)) return;
|
|
7166
8426
|
if (e.shiftKey) { beginShiftClickSelection(e); return; }
|
|
7167
8427
|
armBlockSelDrag(e);
|
|
7168
8428
|
return;
|
|
@@ -7224,12 +8484,30 @@
|
|
|
7224
8484
|
updateBlockSelDrag(e);
|
|
7225
8485
|
});
|
|
7226
8486
|
|
|
8487
|
+
// S4 Task 2: the ⠿ block drag's own pointermove. Registered alongside the
|
|
8488
|
+
// other two rather than folded into either: the table drag's listener bails
|
|
8489
|
+
// on `!tePointer` (the state this one runs in) and the selection drag's bails
|
|
8490
|
+
// on `!blockSelDrag` (likewise). updateBlockDrag()'s own first lines are the
|
|
8491
|
+
// symmetric bail.
|
|
8492
|
+
document.addEventListener('pointermove', (e) => {
|
|
8493
|
+
updateBlockDrag(e);
|
|
8494
|
+
});
|
|
8495
|
+
|
|
7227
8496
|
document.addEventListener('pointerup', async (e) => {
|
|
7228
8497
|
// S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
|
|
7229
8498
|
// this file, so the block-selection drag ends on the pointer events, the
|
|
7230
8499
|
// same skeleton the table drag uses. Before the `!tePointer` bail: a
|
|
7231
8500
|
// selection drag is armed precisely when no grip gesture is in flight.
|
|
7232
8501
|
endBlockSelDrag();
|
|
8502
|
+
// S4 Task 2: and the ⠿ block drag's. Same placement reasoning as the line
|
|
8503
|
+
// above — a block drag is armed precisely when no grip gesture is in
|
|
8504
|
+
// flight, so it must be ended before the `!tePointer` bail.
|
|
8505
|
+
// S4 Task 3: AWAITED, now that the drop writes. endBlockDrag()'s own
|
|
8506
|
+
// teardown runs synchronously (an async function body runs to its first
|
|
8507
|
+
// await), so the `tePointer` branch below is not reordered around any
|
|
8508
|
+
// state it reads; what the await buys is that the commit and its
|
|
8509
|
+
// re-render are complete before this handler returns.
|
|
8510
|
+
await endBlockDrag();
|
|
7233
8511
|
if (!tePointer) return;
|
|
7234
8512
|
const st = tePointer;
|
|
7235
8513
|
releaseTeCapture(st);
|
|
@@ -7275,6 +8553,9 @@
|
|
|
7275
8553
|
// actually moved mid-drag, only the indicator line).
|
|
7276
8554
|
document.addEventListener('pointercancel', () => {
|
|
7277
8555
|
cancelTeDrag();
|
|
8556
|
+
// S4 Task 2: the same abort ends a ⠿ block drag. Nothing was mutated (only
|
|
8557
|
+
// the indicator moved), so this is the same unconditional cleanup.
|
|
8558
|
+
cancelBlockDrag();
|
|
7278
8559
|
// S3 Task 4: the same abort ends a block-selection drag. The selection it
|
|
7279
8560
|
// has built so far STANDS (§4.4: nothing but Escape and a plain click
|
|
7280
8561
|
// clears) — what must not survive is the live drag, or the next stray
|
|
@@ -7291,6 +8572,9 @@
|
|
|
7291
8572
|
// when no gesture is in flight, so this is safe to fire on every blur.
|
|
7292
8573
|
window.addEventListener('blur', () => {
|
|
7293
8574
|
cancelTeDrag();
|
|
8575
|
+
// S4 Task 2: same again for the ⠿ block drag — an undeliverable pointerup
|
|
8576
|
+
// is an undeliverable pointerup whichever gesture is holding it.
|
|
8577
|
+
cancelBlockDrag();
|
|
7294
8578
|
// S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
|
|
7295
8579
|
// drag in flight when focus left is exactly as undeliverable-pointerup as
|
|
7296
8580
|
// a row drag's, so it is torn down here too.
|
|
@@ -7937,6 +9221,11 @@
|
|
|
7937
9221
|
// closeGutterMenu() first, so gutterMenuBlockEl is null by then.
|
|
7938
9222
|
if (e.key === 'Escape') {
|
|
7939
9223
|
if (tePointer && tePointer.dragging) { e.preventDefault(); cancelTeDrag(); return; }
|
|
9224
|
+
// S4 Task 2: the ⠿ block drag gets the same first rung, and for the same
|
|
9225
|
+
// reason — Escape-during-a-drag is a distinct gesture from
|
|
9226
|
+
// Escape-reverts-the-burst, and the drag must be the one that answers.
|
|
9227
|
+
// Mutually exclusive with the rung above (one pointer, one gesture).
|
|
9228
|
+
if (blockDragState && blockDragState.dragging) { e.preventDefault(); cancelBlockDrag(); return; }
|
|
7940
9229
|
if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
|
|
7941
9230
|
if (gutterMenuBlockEl || insertMenuBlockEl) {
|
|
7942
9231
|
e.preventDefault();
|
|
@@ -8312,6 +9601,20 @@
|
|
|
8312
9601
|
hideTableGrips();
|
|
8313
9602
|
return;
|
|
8314
9603
|
}
|
|
9604
|
+
// S4 Task 2: and a third, in the same shape, for the ⠿ block drag —
|
|
9605
|
+
// without it this listener would keep repainting the + bubbles and
|
|
9606
|
+
// repositioning the grips onto whatever row the cursor is dragging
|
|
9607
|
+
// across, on top of the drop indicator, every frame of the gesture. Both
|
|
9608
|
+
// are HIDDEN rather than merely left un-recomputed, so anything already
|
|
9609
|
+
// showing from the frame before the threshold was crossed cannot linger
|
|
9610
|
+
// stale. Unlike the table gate above there is no "active grip" to spare:
|
|
9611
|
+
// the drag's own handle is a per-block button that the pointer has
|
|
9612
|
+
// already left, and the indicator is the whole of the feedback.
|
|
9613
|
+
if (blockDragState && blockDragState.dragging) {
|
|
9614
|
+
hideTableInsertBubbles();
|
|
9615
|
+
hideTableGrips();
|
|
9616
|
+
return;
|
|
9617
|
+
}
|
|
8315
9618
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
8316
9619
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
8317
9620
|
});
|