@helping-ai-workflow/md2doc 2.11.1 → 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 +3010 -326
- package/lib/editor/indent-clamp.js +6 -1
- package/lib/editor/selection.js +204 -0
- package/lib/editor/server.js +7 -0
- package/lib/md2doc.js +149 -28
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -174,6 +174,291 @@
|
|
|
174
174
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
|
|
178
|
+
//
|
|
179
|
+
// A move is ONE commitRangeEdit over
|
|
180
|
+
// `min(source, destination) .. max(source, destination)`. UndoStack's op is
|
|
181
|
+
// `{startLine, endLine, before, after}` — a SINGLE contiguous line range,
|
|
182
|
+
// with no notion of a source and a destination — so a move written as
|
|
183
|
+
// "removal here, insertion there" is two ops and two Ctrl+Z, which §3.4
|
|
184
|
+
// forbids. For a block that is not a li there is nothing to re-serialize:
|
|
185
|
+
// the lines are relocated VERBATIM, so nothing can be re-escaped and no
|
|
186
|
+
// marker arithmetic is involved.
|
|
187
|
+
//
|
|
188
|
+
// THE BLANK-LINE RULE, which the plan left to this task to define:
|
|
189
|
+
//
|
|
190
|
+
// 1. The block's own lines move byte-for-byte.
|
|
191
|
+
// 2. At the seam it LEAVES, the blank run that surrounded it is absorbed
|
|
192
|
+
// and replaced by exactly ONE blank line — or by none when the block
|
|
193
|
+
// was at the start or the end of the file.
|
|
194
|
+
// 3. At the seam it LANDS in, one blank line is emitted on each side of
|
|
195
|
+
// it whose neighbouring line is not already blank.
|
|
196
|
+
//
|
|
197
|
+
// Neither existing helper is the right tool, and the plan says so:
|
|
198
|
+
// commitRangeRemoval() absorbs exactly one adjacent blank and
|
|
199
|
+
// commitBlockInsertion() always adds a leading one. Each is HALF of a move
|
|
200
|
+
// and the halves do not compose — the pair leaves the document one
|
|
201
|
+
// separator short at the seam that was left and one long at the seam that
|
|
202
|
+
// was landed in, and it is two undo ops.
|
|
203
|
+
//
|
|
204
|
+
// CONSEQUENCE, measured against marked.lexer + blockmap.buildBlockMap on
|
|
205
|
+
// every fixture in test/editor-client-runtime.test.js's T3 section before
|
|
206
|
+
// any byte was pinned: in a document whose blocks are separated by exactly
|
|
207
|
+
// one blank line — everything this editor's own serializer emits — a move
|
|
208
|
+
// is a PERMUTATION OF THE FILE'S LINES. Same lines, same count, same
|
|
209
|
+
// separators.
|
|
210
|
+
//
|
|
211
|
+
// The one exception, and it is deliberate: a document that already jams two
|
|
212
|
+
// blocks onto adjacent lines ('# A\n# B\n# C\n') has no blank at the
|
|
213
|
+
// landing seam to carry, so rule 3 CREATES one and the count goes up. It
|
|
214
|
+
// has to. MEASURED: 'paraA\n# H\n\nparaB\n' with paraA appended and no
|
|
215
|
+
// blank emitted is '# H\n\nparaB\nparaA\n', which marked.lexer answers as
|
|
216
|
+
// ONE paragraph — the move would have eaten a block. The count moves only
|
|
217
|
+
// in that direction, and only at a seam that had no separator to begin
|
|
218
|
+
// with.
|
|
219
|
+
//
|
|
220
|
+
// The final '' of a file that ends in a newline is the TERMINATOR, not a
|
|
221
|
+
// blank separator; `contentLen` below is what keeps rule 2 from eating the
|
|
222
|
+
// file's last newline when the block being moved is the last one.
|
|
223
|
+
//
|
|
224
|
+
// Pure: takes and returns plain data, and lives in the node-visible core
|
|
225
|
+
// beside the other commit helpers.
|
|
226
|
+
function planBlockMove(lines, blocks, src, dest) {
|
|
227
|
+
const isBlank = (s) => s !== undefined && s !== null && s.trim() === '';
|
|
228
|
+
const contentLen = lines.length - (lines.length && lines[lines.length - 1] === '' ? 1 : 0);
|
|
229
|
+
let above = 0;
|
|
230
|
+
while (src.startLine - 1 - above >= 1 && isBlank(lines[src.startLine - 2 - above])) above++;
|
|
231
|
+
let below = 0;
|
|
232
|
+
while (src.endLine + 1 + below <= contentLen && isBlank(lines[src.endLine + below])) below++;
|
|
233
|
+
// The source's own extraction range: its lines plus the whole blank run on
|
|
234
|
+
// either side of it. What goes BACK at that seam is rule 2's single blank,
|
|
235
|
+
// and only when the seam has a block on both sides of it.
|
|
236
|
+
const ds = src.startLine - above;
|
|
237
|
+
const de = src.endLine + below;
|
|
238
|
+
const srcFill = (ds >= 2 && de < contentLen) ? [''] : [];
|
|
239
|
+
const blockLines = lines.slice(src.startLine - 1, src.endLine);
|
|
240
|
+
// `dest === null` is the {mode:'append'} target: the insertion point is
|
|
241
|
+
// one line past the last block's last line.
|
|
242
|
+
const ins = dest ? dest.startLine
|
|
243
|
+
: (blocks.length ? blocks[blocks.length - 1].endLine + 1 : contentLen + 1);
|
|
244
|
+
// Dropped where it already is. `ins === ds` is "on my own top edge";
|
|
245
|
+
// `ins === de + 1` is "on the seam immediately below me" — visually the
|
|
246
|
+
// same place, and the byte no-op the drag's own release-where-you-started
|
|
247
|
+
// case depends on. Every other `ins` inside the extraction range is a
|
|
248
|
+
// blank line, which no block's startLine can name.
|
|
249
|
+
if (ins >= ds && ins <= de + 1) return null;
|
|
250
|
+
const prevLine = ins >= 2 ? lines[ins - 2] : null;
|
|
251
|
+
const nextLine = ins <= lines.length ? lines[ins - 1] : null;
|
|
252
|
+
const lead = (prevLine !== null && !isBlank(prevLine)) ? [''] : [];
|
|
253
|
+
const trail = (nextLine !== null && !isBlank(nextLine)) ? [''] : [];
|
|
254
|
+
const payload = lead.concat(blockLines, trail);
|
|
255
|
+
// ONE range, covering both seams. Everything between them is carried
|
|
256
|
+
// through untouched — which is what makes this a single undo op instead
|
|
257
|
+
// of a removal and an insertion.
|
|
258
|
+
if (ins > de + 1) {
|
|
259
|
+
const after = srcFill.concat(lines.slice(de, ins - 1), payload);
|
|
260
|
+
const newStart = ds + srcFill.length + (ins - 1 - de) + lead.length;
|
|
261
|
+
return { startLine: ds, endLine: ins - 1, after: after,
|
|
262
|
+
newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
|
|
263
|
+
}
|
|
264
|
+
const after = payload.concat(lines.slice(ins - 1, ds - 1), srcFill);
|
|
265
|
+
const newStart = ins + lead.length;
|
|
266
|
+
return { startLine: ins, endLine: de, after: after,
|
|
267
|
+
newRange: { startLine: newStart, endLine: newStart + blockLines.length - 1 } };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The commit half. `op === null` with `noop` set means the block was
|
|
271
|
+
// released where it already was — a BYTE no-op, not a failure, and
|
|
272
|
+
// deliberately distinct from commitRangeEdit()'s own unchanged-text
|
|
273
|
+
// `op: null` so a caller can tell "nothing to do" from "somebody handed me
|
|
274
|
+
// a range that produced the same text".
|
|
275
|
+
function commitBlockMove(state, src, dest) {
|
|
276
|
+
const plan = planBlockMove(state.lines, state.blocks, src, dest);
|
|
277
|
+
if (!plan) {
|
|
278
|
+
return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
|
|
279
|
+
}
|
|
280
|
+
const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'));
|
|
281
|
+
r.newRange = plan.newRange;
|
|
282
|
+
return r;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── S4 Task 4: the pure half of a LIST ITEM's move (spec §4.5) ─────────
|
|
286
|
+
//
|
|
287
|
+
// A li's lines CANNOT be relocated the way planBlockMove() relocates every
|
|
288
|
+
// other block's — verbatim. Markers and ordinals are RUN-GLOBAL: '3. charlie'
|
|
289
|
+
// moved to the head of its run has to come back as '1. charlie' with
|
|
290
|
+
// everything behind it renumbered, and its indent prefix is a column count
|
|
291
|
+
// measured against the marker widths standing above it. So a li's move is
|
|
292
|
+
// expressed the way duplicateListItems() expresses a duplicate — as a
|
|
293
|
+
// REORDERED SPAN ARRAY handed to serializeBlocks(), committed over the run's
|
|
294
|
+
// own line range as ONE commitRangeEdit (§3.4: one gesture, one undo op).
|
|
295
|
+
// These two functions are the parts of that reasoning with no DOM in them.
|
|
296
|
+
|
|
297
|
+
// The span order after "the `count` members starting at `from` go
|
|
298
|
+
// immediately before the member at `insertAt`". `insertAt` names a slot in
|
|
299
|
+
// the array BEFORE the removal — it comes straight from the drop target's
|
|
300
|
+
// `before-block N` — so a DOWNWARD move is decremented by the WHOLE count
|
|
301
|
+
// and an upward one is not decremented at all: the same `insertAt` means two
|
|
302
|
+
// different slots depending on the direction, which is the whole reason this
|
|
303
|
+
// is a named function with its own test rather than a splice pair at the
|
|
304
|
+
// call site.
|
|
305
|
+
//
|
|
306
|
+
// S4 Task 6 widened it from one member to `count`, in place rather than
|
|
307
|
+
// beside: §4.5's 「grip 在選取集合內 → 整批搬」 is the SAME arithmetic and a
|
|
308
|
+
// second copy of an off-by-one is the 「不得另寫一條」 shape §3.6 has already
|
|
309
|
+
// ruled against twice. The single-item case is `count === 1` and its whole
|
|
310
|
+
// test table was migrated onto this signature unchanged.
|
|
311
|
+
//
|
|
312
|
+
// `null` means the order would be UNCHANGED, which is a byte no-op and not a
|
|
313
|
+
// failure: every `insertAt` from `from` to `from + count` inclusive is the
|
|
314
|
+
// SET'S OWN FOOTPRINT — `from` is "released on my own top edge",
|
|
315
|
+
// `from + count` is "released on the seam immediately below me", and the
|
|
316
|
+
// slots in between name a position INSIDE the set, which is not a reorder of
|
|
317
|
+
// the span but a shuffle of members that are travelling together. Visually
|
|
318
|
+
// they are all the same place, and they are the case the drag's own
|
|
319
|
+
// release-where-you-started gesture depends on. An out-of-range index, an
|
|
320
|
+
// empty set and a set that runs off the end answer `null` too; there is no
|
|
321
|
+
// order any of them could name.
|
|
322
|
+
function reorderSpanRange(length, from, count, insertAt) {
|
|
323
|
+
if (!(length > 0) || !(count > 0)) return null;
|
|
324
|
+
if (!(from >= 0) || from + count > length) return null;
|
|
325
|
+
if (!(insertAt >= 0) || insertAt > length) return null;
|
|
326
|
+
if (insertAt >= from && insertAt <= from + count) return null;
|
|
327
|
+
const rest = [];
|
|
328
|
+
const moved = [];
|
|
329
|
+
for (let i = 0; i < length; i++) {
|
|
330
|
+
if (i >= from && i < from + count) moved.push(i); else rest.push(i);
|
|
331
|
+
}
|
|
332
|
+
const at = insertAt > from ? insertAt - count : insertAt;
|
|
333
|
+
return rest.slice(0, at).concat(moved, rest.slice(at));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── S4 Task 6: the LINE RANGE a batch move relocates (spec §3.3, §4.5) ──
|
|
337
|
+
//
|
|
338
|
+
// §3.3's operand set is CONTIGUOUS in block order — resolveGutterOperands()
|
|
339
|
+
// refuses a gap before anything reaches here — so its members' lines plus
|
|
340
|
+
// the separators standing between them are ONE contiguous run, which is
|
|
341
|
+
// exactly what planBlockMove() relocates verbatim. That is the whole reason
|
|
342
|
+
// a batch move is the SAME single commitRangeEdit a one-block move is, and
|
|
343
|
+
// therefore the same single Ctrl+Z (§3.4).
|
|
344
|
+
//
|
|
345
|
+
// Named rather than written as an object literal at the call site because
|
|
346
|
+
// the set reaches the bytes through this one expression and nothing else: an
|
|
347
|
+
// endLine taken from any member but the LAST leaves the rest of the set
|
|
348
|
+
// standing where it was while its head travels, and the blank-line rules
|
|
349
|
+
// then re-emit separators around both halves — a document the user never
|
|
350
|
+
// asked for, from a one-token typo. A pure function is a mutation a test can
|
|
351
|
+
// kill in a millisecond instead of a browser round trip.
|
|
352
|
+
function spanMoveRange(recs) {
|
|
353
|
+
if (!recs || !recs.length) return null;
|
|
354
|
+
return { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// §4.5's INDENT SEAM, asked of the span AS IT WOULD BE COMMITTED.
|
|
358
|
+
//
|
|
359
|
+
// Markdown indent is only ever RELATIVE (indent-clamp.js's own opening
|
|
360
|
+
// sentence): an item's depth exists solely because a shallower item stands
|
|
361
|
+
// above it. serializeBlocks() rebuilds its marker-width stack as it walks the
|
|
362
|
+
// array, from empty — so a span whose FIRST block claims `data-indent="1"`
|
|
363
|
+
// emits that block at column 0, and the DOM and the file then disagree about
|
|
364
|
+
// the nesting. A move creates exactly that by lifting an item out from above
|
|
365
|
+
// its own child.
|
|
366
|
+
//
|
|
367
|
+
// The bound is deliberately the one indent-clamp.js's `boundAt()` computes
|
|
368
|
+
// — the previous li's indent + 1, and 0 when there is no previous li or the
|
|
369
|
+
// previous block is not one — so the predicate and the clamp cannot drift.
|
|
370
|
+
//
|
|
371
|
+
// S4 Task 7 KEPT it and moved it to AFTER the clamp. Asked of the raw
|
|
372
|
+
// reordered span it refused 1425 of the 4067 legal drops; the clamp now
|
|
373
|
+
// answers 1411 of those, and what this still refuses is the 14 no
|
|
374
|
+
// removal-clamp can reach — a batch whose last member is shallower than the
|
|
375
|
+
// block it lands before, i.e. the INSERTION half of the move. See
|
|
376
|
+
// performListItemDrop() for the single site and BLOCK_MOVE_ORPHAN_MESSAGE
|
|
377
|
+
// for the family and the sweep behind those numbers.
|
|
378
|
+
function spanIndentsAreAnchored(items) {
|
|
379
|
+
let prevLi = null;
|
|
380
|
+
for (let i = 0; i < (items || []).length; i++) {
|
|
381
|
+
const it = items[i] || {};
|
|
382
|
+
if (it.type !== 'li') { prevLi = null; continue; }
|
|
383
|
+
const indent = typeof it.indent === 'number' ? it.indent : 0;
|
|
384
|
+
if (indent > (prevLi === null ? 0 : prevLi + 1)) return false;
|
|
385
|
+
prevLi = indent;
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── S4 Task 5: the CROSS-BOUNDARY seam predicate (spec §4.5) ──────────
|
|
391
|
+
//
|
|
392
|
+
// §4.5's ruling, made by the user on 2026-09-01 and implemented rather than
|
|
393
|
+
// re-opened: a cross-boundary move is REFUSED with a banner in 3.0.0 and the
|
|
394
|
+
// capability deferred to 3.1.0, whose first step is amending the spec with
|
|
395
|
+
// the blank-line rule for a run/non-run seam. This function is the whole of
|
|
396
|
+
// "which moves cross a boundary" for a block that is NOT a list item; a li
|
|
397
|
+
// source has its own, narrower question (its destination must be a slot in
|
|
398
|
+
// its own §3.8 run) and answers it in performListItemDrop().
|
|
399
|
+
//
|
|
400
|
+
// A move touches TWO seams and they need DIFFERENT rules. That asymmetry is
|
|
401
|
+
// the task's finding, and every row below is a marked.lexer measurement:
|
|
402
|
+
//
|
|
403
|
+
// DESTINATION — where the block LANDS. An insertion can only ever SPLIT;
|
|
404
|
+
// it has no way to merge two lists. So the only thing it can break is a
|
|
405
|
+
// run it lands INSIDE, and the question is exactly "are these two
|
|
406
|
+
// neighbours members of ONE run":
|
|
407
|
+
// '# Doc\n\npara\n\n- a\n- b\n\ntail\n' with `para` spliced
|
|
408
|
+
// between the items -> list(1) | paragraph | list(1): one two-item run
|
|
409
|
+
// became two one-item runs, and the blanks rule 3 emits either side
|
|
410
|
+
// are §4.3's looseness trap. REFUSED.
|
|
411
|
+
// '# Doc\n\n- a\n\n1. b\n\ntail\n' with `para` spliced at the
|
|
412
|
+
// same-looking seam -> heading | ul(1,tight) | paragraph |
|
|
413
|
+
// ol(1,tight) | paragraph. Nothing merged, nothing loosened, because
|
|
414
|
+
// these were already two lists. ACCEPTED — this is Task 3's
|
|
415
|
+
// deliberate over-refusal, and narrowing it is what Task 5 owed.
|
|
416
|
+
//
|
|
417
|
+
// SOURCE — where the block LEAVES. A removal MERGES, and it CANNOT be
|
|
418
|
+
// narrowed with anything the block model carries. Every one of these
|
|
419
|
+
// leaves ONE list with `loose === true`, i.e. every item renders as a <p>,
|
|
420
|
+
// serializeBlocks() reports 'P' for each, and the whole run degrades
|
|
421
|
+
// read-only with no banner:
|
|
422
|
+
// '- a' | para | '- b' -> '- a\n\n- b\n'
|
|
423
|
+
// '1. a' | para | '2. b' -> '1. a\n\n2. b\n'
|
|
424
|
+
// '- [ ] a' | para | '- [x] b' -> '- [ ] a\n\n- [x] b\n'
|
|
425
|
+
// '- a' | para | ' 1. a1' -> '- a\n\n 1. a1\n'
|
|
426
|
+
// The last one is why the obvious narrowings are all wrong. blockmap
|
|
427
|
+
// reports those two items as (indent 0, listType 'ul') and (indent 0,
|
|
428
|
+
// listType 'ol') — DIFFERENT runs AND different list types, so every fact
|
|
429
|
+
// the model exposes says "two lists, safe", while the file says loose.
|
|
430
|
+
// What decides it is the two-space prefix on a raw line, which no block
|
|
431
|
+
// attribute carries and which the client cannot lex (render is a server
|
|
432
|
+
// round trip; there is no lexer in the browser). The mirror over-refusal
|
|
433
|
+
// is '- a' | para | '* b' -> '- a\n\n* b\n', measurably TWO tight
|
|
434
|
+
// lists and still refused, because the discriminator there is the bullet
|
|
435
|
+
// CHARACTER — another raw byte the model does not carry. Recorded in §4.5
|
|
436
|
+
// as a deliberate over-refusal for 3.1.0, not as a correctness claim.
|
|
437
|
+
//
|
|
438
|
+
// `srcSeam` / `destSeam` are `{prev, next}`; each side is null when there is
|
|
439
|
+
// no such block or it is not a li, and otherwise `{runKey}` — the
|
|
440
|
+
// `data-block-id` of the FIRST member of listRunOf(), which is §3.4 rule 2's
|
|
441
|
+
// scope, so a nested child and its parent answer the SAME key. A null
|
|
442
|
+
// runKey (a run the DOM could not answer) counts as "same": an unknown seam
|
|
443
|
+
// refuses rather than guesses, because the guess that goes wrong corrupts a
|
|
444
|
+
// list and corrupting a list is not undoable.
|
|
445
|
+
//
|
|
446
|
+
// Returns 'source' | 'destination' | null. When both seams object the
|
|
447
|
+
// SOURCE one is reported: it is the objection that holds wherever the user
|
|
448
|
+
// aims next, so it is the one worth telling them about.
|
|
449
|
+
//
|
|
450
|
+
// Pure, and exported, because the enumeration it encodes is a table of
|
|
451
|
+
// measurements — a gesture-level test can only reach a handful of its rows.
|
|
452
|
+
function blockMoveSeamRefusal(srcSeam, destSeam) {
|
|
453
|
+
const s = srcSeam || {};
|
|
454
|
+
const d = destSeam || {};
|
|
455
|
+
if (s.prev && s.next) return 'source';
|
|
456
|
+
if (d.prev && d.next &&
|
|
457
|
+
(d.prev.runKey === null || d.next.runKey === null ||
|
|
458
|
+
d.prev.runKey === d.next.runKey)) return 'destination';
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
177
462
|
// ── T8 review MEDIUM-1: the one correct way to undo a failed render ─────
|
|
178
463
|
// Six sites in this file share the shape "commit optimistically, re-render,
|
|
179
464
|
// and if the render failed put `lines` back". The inlined version of that
|
|
@@ -222,7 +507,7 @@
|
|
|
222
507
|
}
|
|
223
508
|
|
|
224
509
|
if (typeof module === 'object' && module.exports) {
|
|
225
|
-
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
510
|
+
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
226
511
|
return; // node: pure core only
|
|
227
512
|
}
|
|
228
513
|
|
|
@@ -233,6 +518,7 @@
|
|
|
233
518
|
const listMd = window.md2docListMd;
|
|
234
519
|
const indentClamp = window.md2docIndentClamp;
|
|
235
520
|
const convertMd = window.md2docConvertMd;
|
|
521
|
+
const selectionLib = window.md2docSelection;
|
|
236
522
|
const historyLib = window.md2docHistory;
|
|
237
523
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
238
524
|
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
@@ -440,8 +726,19 @@
|
|
|
440
726
|
// extra button ahead of the always-present ✕ dismiss button (e.g. the
|
|
441
727
|
// conflict banner's "Reload"); omit them for a plain dismiss-only notice.
|
|
442
728
|
let activeBanner = null;
|
|
729
|
+
// Review recommendation 5 (2026-08-31): whether the banner on screen is a
|
|
730
|
+
// structural REFUSAL — 「無法整批操作」 and its siblings — as opposed to a
|
|
731
|
+
// conflict / render-failed / save-failed notice. Only a refusal is cleared
|
|
732
|
+
// by a later gesture that succeeds (see dismissRefusalBanner()); the other
|
|
733
|
+
// three describe the state of the FILE or the connection and must stay until
|
|
734
|
+
// the user dismisses them.
|
|
735
|
+
let activeBannerIsRefusal = false;
|
|
443
736
|
function showBanner(message, actionLabel, onAction) {
|
|
444
737
|
if (activeBanner) { activeBanner.remove(); activeBanner = null; }
|
|
738
|
+
// Reset unconditionally: refuseStructuralListEdit() sets it back to true
|
|
739
|
+
// straight after its own call, so every OTHER caller gets false without
|
|
740
|
+
// having to know this flag exists.
|
|
741
|
+
activeBannerIsRefusal = false;
|
|
445
742
|
const el = document.createElement('div');
|
|
446
743
|
el.className = 'ed-conflict';
|
|
447
744
|
const msg = document.createElement('span');
|
|
@@ -460,7 +757,7 @@
|
|
|
460
757
|
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
461
758
|
dismissBtn.addEventListener('click', () => {
|
|
462
759
|
el.remove();
|
|
463
|
-
if (activeBanner === el) activeBanner = null;
|
|
760
|
+
if (activeBanner === el) { activeBanner = null; activeBannerIsRefusal = false; }
|
|
464
761
|
});
|
|
465
762
|
el.appendChild(dismissBtn);
|
|
466
763
|
document.body.appendChild(el);
|
|
@@ -468,6 +765,24 @@
|
|
|
468
765
|
return el;
|
|
469
766
|
}
|
|
470
767
|
|
|
768
|
+
// Review recommendation 5: a refusal banner must not outlive the gesture that
|
|
769
|
+
// raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
|
|
770
|
+
// S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
|
|
771
|
+
// refuse), so 「選取範圍同時含有清單項目與其他區塊,無法整批操作」 could stand
|
|
772
|
+
// over a document the user had since successfully edited — a plain lie about
|
|
773
|
+
// the state of the document. Called from rerenderAll()'s success path, which
|
|
774
|
+
// is the one point every structural operation reaches only by having WORKED:
|
|
775
|
+
// every failure exit above it returns before this. Deliberately NOT called
|
|
776
|
+
// from the Escape / clear-selection paths — dismissing a selection is not the
|
|
777
|
+
// same event as a later gesture succeeding, and the refusal is still the true
|
|
778
|
+
// answer to the gesture the user last attempted.
|
|
779
|
+
function dismissRefusalBanner() {
|
|
780
|
+
if (!activeBanner || !activeBannerIsRefusal) return;
|
|
781
|
+
activeBanner.remove();
|
|
782
|
+
activeBanner = null;
|
|
783
|
+
activeBannerIsRefusal = false;
|
|
784
|
+
}
|
|
785
|
+
|
|
471
786
|
function showConflictBanner() {
|
|
472
787
|
showBanner(
|
|
473
788
|
'File changed on disk — reload to pick up external edits ' +
|
|
@@ -503,6 +818,14 @@
|
|
|
503
818
|
// await is inside its own try/catch, so callers never see a rejection.
|
|
504
819
|
async function rerenderAll() {
|
|
505
820
|
const scrollY = window.scrollY;
|
|
821
|
+
// S3 Task 5 (§4.4 step 2): the line range this render's operation declared
|
|
822
|
+
// for the rebuilt selection. Consumed HERE, before the first failure exit,
|
|
823
|
+
// rather than down at the rebuild itself: every `return false` below
|
|
824
|
+
// leaves `blockSelection` untouched — which is what makes a line-range
|
|
825
|
+
// selection survive a failed render for free — and a declaration left
|
|
826
|
+
// standing would then land on some LATER, unrelated render instead.
|
|
827
|
+
const declaredRange = pendingSelectionRange;
|
|
828
|
+
pendingSelectionRange = undefined;
|
|
506
829
|
let res;
|
|
507
830
|
try {
|
|
508
831
|
res = await fetch('/api/render', {
|
|
@@ -530,6 +853,10 @@
|
|
|
530
853
|
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
531
854
|
return false;
|
|
532
855
|
}
|
|
856
|
+
// Past every failure exit: this render is going to happen, so whatever
|
|
857
|
+
// gesture asked for it has succeeded. Review recommendation 5 — a stale
|
|
858
|
+
// structural refusal is cleared HERE and nowhere else.
|
|
859
|
+
dismissRefusalBanner();
|
|
533
860
|
blocks = j.blocks;
|
|
534
861
|
contentEl.innerHTML = j.bodyHtml;
|
|
535
862
|
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
@@ -593,6 +920,11 @@
|
|
|
593
920
|
// ago was just destroyed by the innerHTML replacement above.
|
|
594
921
|
hideTableEdgeMenu();
|
|
595
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();
|
|
596
928
|
// Final-review Finding 5b (Important): same belt-and-braces reasoning
|
|
597
929
|
// again for the ⠿ gutter menu — `toggleGutterMenu()` appends the ONE
|
|
598
930
|
// shared `gutterMenu` node as a CHILD of whichever block it's open for
|
|
@@ -611,6 +943,17 @@
|
|
|
611
943
|
// it's the same "singleton node appended as a child of whichever block
|
|
612
944
|
// it's open for" idiom as gutterMenu just above.
|
|
613
945
|
closeInsertMenu();
|
|
946
|
+
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
|
|
947
|
+
// 1; this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
948
|
+
// still-standing) LINE RANGE against the freshly built `blocks`, clear it
|
|
949
|
+
// if that range no longer resolves, and give the focus endpoint a real
|
|
950
|
+
// roving-tabindex holder. It sits AFTER the unconditional teardown above
|
|
951
|
+
// so nothing there can null what it just set, and BEFORE the two
|
|
952
|
+
// `try`-swallowed rebind blocks below so a diagram-init throw can never
|
|
953
|
+
// skip it. It is also deliberately ABOVE window.scrollTo(): the .focus()
|
|
954
|
+
// it ends in scrolls the holder into view, and the scroll restore below
|
|
955
|
+
// is what puts the reader back where they were.
|
|
956
|
+
rebuildBlockSelection(declaredRange);
|
|
614
957
|
window.scrollTo(0, scrollY);
|
|
615
958
|
if (window.__md2docInitDiagrams) {
|
|
616
959
|
try {
|
|
@@ -1294,23 +1637,66 @@
|
|
|
1294
1637
|
// has to: `opIndex` is an index into it, and rule 2's scope starts after it);
|
|
1295
1638
|
// clampIndents() reports no indent for a removed block, so the write-back
|
|
1296
1639
|
// below never touches the element that is on its way out.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1640
|
+
//
|
|
1641
|
+
// S3 Task 6: `opBlockEl` may also be an ARRAY — spec §3.4 rule 3's multi-block
|
|
1642
|
+
// operation, which clampIndents() has accepted since S1 (`opIndex` may be an
|
|
1643
|
+
// array of indices). `opOldIndent` is then the SMALLEST old indent in the set,
|
|
1644
|
+
// never the first member's: §3.4 rule 3 records both ways the first member
|
|
1645
|
+
// goes wrong (a delete drives a later member to indent −1; a batch Tab whose
|
|
1646
|
+
// first member is already at its ceiling no-ops the whole set). The caller
|
|
1647
|
+
// computes it with spanMinIndent(). A member that is not in `spanEls` aborts
|
|
1648
|
+
// the whole clamp rather than clamping a subset — a partial op set makes rule
|
|
1649
|
+
// 2's scope start in the wrong place, which is a silent wrong answer.
|
|
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;
|
|
1670
|
+
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1671
|
+
const opIndex = [];
|
|
1672
|
+
for (let k = 0; k < opEls.length; k++) {
|
|
1673
|
+
const at = spanEls.indexOf(opEls[k]);
|
|
1674
|
+
if (at < 0) return null;
|
|
1675
|
+
opIndex.push(at);
|
|
1676
|
+
}
|
|
1677
|
+
if (!opIndex.length) return null;
|
|
1301
1678
|
const model = spanEls.map((el, i) => ({
|
|
1302
1679
|
id: i, // index-as-id: the span IS the universe here
|
|
1303
1680
|
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
1304
1681
|
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
1305
1682
|
}));
|
|
1306
|
-
indentClamp.clampIndents(model, opIndex, opOldIndent, opts || {})
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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);
|
|
1310
1692
|
}
|
|
1311
1693
|
});
|
|
1312
1694
|
}
|
|
1313
1695
|
|
|
1696
|
+
function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1697
|
+
writeIndentClamp(computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts));
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1314
1700
|
function placeCaretAtEnd(el) {
|
|
1315
1701
|
const range = document.createRange();
|
|
1316
1702
|
range.selectNodeContents(el);
|
|
@@ -1596,35 +1982,175 @@
|
|
|
1596
1982
|
// without a second selector.
|
|
1597
1983
|
let convertSubmenu = null;
|
|
1598
1984
|
|
|
1985
|
+
// ── v2.12.0 Task 4b, half 1: an icon at the head of every item ──────────
|
|
1986
|
+
// User request: 「選單每個功能開頭給一個圖示,完全照抄 notion」. Notion's
|
|
1987
|
+
// visual LANGUAGE, drawn here rather than their assets copied: 16x16 on a
|
|
1988
|
+
// 0 0 16 16 viewBox, 1.5px stroke, fill:none, round caps and joins, and
|
|
1989
|
+
// stroke="currentColor" so an icon is simply the colour of the row it sits
|
|
1990
|
+
// in — retheme .ed-handle-menu and the icons follow for free, which a hex
|
|
1991
|
+
// literal here would break silently.
|
|
1992
|
+
//
|
|
1993
|
+
// The menu is a SINGLETON built once at module scope and moved between
|
|
1994
|
+
// blocks, so this markup is parsed exactly once for the life of the page.
|
|
1995
|
+
// Nothing here may run per open.
|
|
1996
|
+
const MENU_ICON_PATHS = {
|
|
1997
|
+
// 轉換成 — a turn/redirect arrow: out to the right, then down. "this block
|
|
1998
|
+
// becomes that one".
|
|
1999
|
+
convert: '<path d="M2.5 4.5h6a3 3 0 0 1 3 3v4.6"/><path d="M9 9.6l2.5 2.5 2.5-2.5"/>',
|
|
2000
|
+
// 建立副本 — two offset rounded cards, the standard duplicate glyph. The
|
|
2001
|
+
// back card is an L-shaped outline rather than a second full rect so the
|
|
2002
|
+
// two do not draw a line through each other.
|
|
2003
|
+
duplicate: '<rect x="5.5" y="5.5" width="8" height="8" rx="2"/>' +
|
|
2004
|
+
'<path d="M10.5 5.5V4.5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h1"/>',
|
|
2005
|
+
// 刪除 — a trash can: lid, handle, tapered body, two ribs.
|
|
2006
|
+
trash: '<path d="M2.5 4.5h11"/>' +
|
|
2007
|
+
'<path d="M6.4 4.5V3.2a1.2 1.2 0 0 1 1.2-1.2h.8a1.2 1.2 0 0 1 1.2 1.2v1.3"/>' +
|
|
2008
|
+
'<path d="M4.2 4.5l.6 8.1a1.4 1.4 0 0 0 1.4 1.3h3.6a1.4 1.4 0 0 0 1.4-1.3l.6-8.1"/>' +
|
|
2009
|
+
'<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
|
|
2010
|
+
// MD 原始碼 — angle brackets: source, not prose.
|
|
2011
|
+
code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
|
|
2012
|
+
};
|
|
2013
|
+
function menuIconMarkup(name) {
|
|
2014
|
+
return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
|
|
2015
|
+
'aria-hidden="true" focusable="false" fill="none" stroke="currentColor" ' +
|
|
2016
|
+
'stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
|
|
2017
|
+
MENU_ICON_PATHS[name] + '</svg>';
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// ── v2.12.0 Task 4b, half 2: the submenu opens on HOVER ─────────────────
|
|
2021
|
+
// User request: 「"轉換成" hover 沒有自動顯示子選單」.
|
|
2022
|
+
//
|
|
2023
|
+
// The naive "open on mouseenter, close on mouseleave" reproduces the gutter
|
|
2024
|
+
// corridor defect v2.11.1 had just finished fixing, and MEASURED on this
|
|
2025
|
+
// branch at 1400x900 it is worse than the plan predicted:
|
|
2026
|
+
//
|
|
2027
|
+
// * `.ed-handle-submenu { left: 100%; margin-left: 4px }` over a menu with
|
|
2028
|
+
// 4px of padding leaves x in [item.right + 4, sub.left) — exactly 4
|
|
2029
|
+
// device px on the item's own row — hit-testing to the BLOCK underneath.
|
|
2030
|
+
// A mouseleave-closes rule shuts the panel while the pointer is inside
|
|
2031
|
+
// that band, on its way in.
|
|
2032
|
+
// * The far worse one: the submenu is 342px tall against the menu's 118px,
|
|
2033
|
+
// so a straight line from 轉換成 to the panel's LAST row is 344px long
|
|
2034
|
+
// and leaves the item/panel pair for 334ms at 1200px/s, 367ms at 600,
|
|
2035
|
+
// 535ms at 300 and 1068ms at 150 — first across 建立副本 / 刪除 /
|
|
2036
|
+
// MD 原始碼, then across bare page BELOW the menu. No close delay covers
|
|
2037
|
+
// that AND still shuts the panel promptly when the user really has
|
|
2038
|
+
// settled on 刪除; the plan's suggested 150-250ms covers neither end.
|
|
2039
|
+
//
|
|
2040
|
+
// So the rule is the classic menu-aim one, and it is a DIRECTION test rather
|
|
2041
|
+
// than a distance or a timer: while the pointer is moving INTO the panel —
|
|
2042
|
+
// inside the triangle whose apex is where it was one sample ago and whose
|
|
2043
|
+
// base is the panel's near (left) edge, top to bottom — nothing closes the
|
|
2044
|
+
// submenu, whatever it happens to be passing over on the way. Every other
|
|
2045
|
+
// sample (a different parent item, bare page, off the menu entirely)
|
|
2046
|
+
// SCHEDULES the close, and the grace period below is only what covers a
|
|
2047
|
+
// single sample the triangle misses. Escape / outside click /
|
|
2048
|
+
// closeGutterMenu() are unchanged and still immediate.
|
|
2049
|
+
const SUBMENU_CLOSE_MS = 300;
|
|
2050
|
+
let submenuCloseTimer = null;
|
|
2051
|
+
let submenuAimPrev = null;
|
|
2052
|
+
// Whether the standing panel was opened by the pointer rather than by a
|
|
2053
|
+
// click — see the 轉換成 item's own handler for what it is for.
|
|
2054
|
+
let convertSubmenuViaHover = false;
|
|
2055
|
+
|
|
2056
|
+
function cancelSubmenuClose() {
|
|
2057
|
+
if (submenuCloseTimer !== null) { clearTimeout(submenuCloseTimer); submenuCloseTimer = null; }
|
|
2058
|
+
}
|
|
2059
|
+
function scheduleSubmenuClose() {
|
|
2060
|
+
// Deliberately NOT restarted while one is already counting down: the
|
|
2061
|
+
// countdown starts at the first sample that says "not on the way in", and
|
|
2062
|
+
// any sample that says otherwise cancels it outright. Restarting per
|
|
2063
|
+
// mousemove would make the close time depend on how much the user jiggles.
|
|
2064
|
+
if (submenuCloseTimer !== null) return;
|
|
2065
|
+
submenuCloseTimer = setTimeout(() => {
|
|
2066
|
+
submenuCloseTimer = null;
|
|
2067
|
+
closeConvertSubmenu();
|
|
2068
|
+
}, SUBMENU_CLOSE_MS);
|
|
2069
|
+
}
|
|
2070
|
+
function pointInTriangle(px, py, ax, ay, bx, by, cx, cy) {
|
|
2071
|
+
const cross = (x1, y1, x2, y2, x3, y3) => (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
|
|
2072
|
+
const d1 = cross(px, py, ax, ay, bx, by);
|
|
2073
|
+
const d2 = cross(px, py, bx, by, cx, cy);
|
|
2074
|
+
const d3 = cross(px, py, cx, cy, ax, ay);
|
|
2075
|
+
return !(((d1 < 0) || (d2 < 0) || (d3 < 0)) && ((d1 > 0) || (d2 > 0) || (d3 > 0)));
|
|
2076
|
+
}
|
|
2077
|
+
function onSubmenuPointerMove(e) {
|
|
2078
|
+
if (!convertSubmenu) return;
|
|
2079
|
+
const prev = submenuAimPrev;
|
|
2080
|
+
submenuAimPrev = { x: e.clientX, y: e.clientY };
|
|
2081
|
+
const t = e.target;
|
|
2082
|
+
const inSub = !!(t && t.closest && t.closest('.ed-handle-submenu'));
|
|
2083
|
+
const btn = t && t.closest ? t.closest('.ed-handle-menu-btn') : null;
|
|
2084
|
+
if (inSub || (btn && btn === gutterMenuConvert)) { cancelSubmenuClose(); return; }
|
|
2085
|
+
if (prev) {
|
|
2086
|
+
const r = convertSubmenu.getBoundingClientRect();
|
|
2087
|
+
if (pointInTriangle(e.clientX, e.clientY, prev.x, prev.y, r.left, r.top, r.left, r.bottom)) {
|
|
2088
|
+
cancelSubmenuClose();
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
scheduleSubmenuClose();
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1599
2095
|
function buildGutterMenu() {
|
|
1600
2096
|
const el = document.createElement('div');
|
|
1601
2097
|
el.className = 'ed-handle-menu';
|
|
1602
2098
|
|
|
1603
|
-
function item(label, aria, onClick) {
|
|
2099
|
+
function item(label, aria, onClick, icon) {
|
|
1604
2100
|
const b = document.createElement('button');
|
|
1605
2101
|
b.type = 'button';
|
|
1606
2102
|
b.className = 'ed-handle-menu-btn';
|
|
1607
|
-
|
|
2103
|
+
// The icon first, then the label as a bare TEXT NODE. Never
|
|
2104
|
+
// `b.textContent = label` after this (it would wipe the icon), and
|
|
2105
|
+
// deliberately no wrapper element around the label: every menu helper in
|
|
2106
|
+
// this repo and in test/editor-client-runtime.test.js finds an item by
|
|
2107
|
+
// EXACT `b.textContent`, and an <svg> contributes none of its own, so the
|
|
2108
|
+
// label reads back byte-identical with the icon in front of it.
|
|
2109
|
+
b.innerHTML = menuIconMarkup(icon);
|
|
2110
|
+
b.appendChild(document.createTextNode(label));
|
|
1608
2111
|
b.setAttribute('aria-label', aria);
|
|
1609
2112
|
b.addEventListener('click', onClick);
|
|
1610
2113
|
el.appendChild(b);
|
|
1611
2114
|
return b;
|
|
1612
2115
|
}
|
|
1613
2116
|
|
|
2117
|
+
// Hover-open. One delegated listener on the singleton menu, for the life of
|
|
2118
|
+
// the page. The submenu is a CHILD of the menu so its own mouseovers bubble
|
|
2119
|
+
// through here too, which is why this is an IDENTITY check against
|
|
2120
|
+
// gutterMenuConvert rather than a label match.
|
|
2121
|
+
el.addEventListener('mouseover', (e) => {
|
|
2122
|
+
const btn = e.target && e.target.closest ? e.target.closest('.ed-handle-menu-btn') : null;
|
|
2123
|
+
if (!gutterMenuConvert || btn !== gutterMenuConvert || gutterMenuConvert.hidden) return;
|
|
2124
|
+
cancelSubmenuClose();
|
|
2125
|
+
if (!convertSubmenu) openConvertSubmenu(gutterMenuConvert, true);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
1614
2128
|
// 轉換成 is the one item that does NOT close the menu — it grows a
|
|
1615
2129
|
// submenu, and a second press folds it back up.
|
|
1616
2130
|
gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
|
|
1617
2131
|
e.stopPropagation();
|
|
1618
|
-
if (convertSubmenu) {
|
|
1619
|
-
|
|
1620
|
-
|
|
2132
|
+
if (convertSubmenu) {
|
|
2133
|
+
// A click folds a CLICK-opened panel back up — the S2 toggle, which is
|
|
2134
|
+
// the path clickGutterMenuItem() / convertVia() drive throughout the
|
|
2135
|
+
// test suite and which must not regress. It must NOT fold up a panel
|
|
2136
|
+
// the pointer's own hover just opened: the pointer is on the item, so
|
|
2137
|
+
// no further mouseover would ever fire, and the panel would become
|
|
2138
|
+
// unreachable by mouse — the exact opposite of what was asked for.
|
|
2139
|
+
// The click does consume the hover flag, so a second one still folds.
|
|
2140
|
+
if (!convertSubmenuViaHover) { closeConvertSubmenu(); return; }
|
|
2141
|
+
convertSubmenuViaHover = false;
|
|
2142
|
+
cancelSubmenuClose();
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
openConvertSubmenu(gutterMenuConvert, false);
|
|
2146
|
+
}, 'convert');
|
|
1621
2147
|
|
|
1622
2148
|
gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
|
|
1623
2149
|
e.stopPropagation();
|
|
1624
2150
|
const blockEl = gutterMenuBlockEl;
|
|
1625
2151
|
closeGutterMenu();
|
|
1626
2152
|
duplicateBlockViaMenu(blockEl);
|
|
1627
|
-
});
|
|
2153
|
+
}, 'duplicate');
|
|
1628
2154
|
|
|
1629
2155
|
// §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
|
|
1630
2156
|
// unchanged (that function was already fully block-type-agnostic —
|
|
@@ -1638,14 +2164,14 @@
|
|
|
1638
2164
|
const blockEl = gutterMenuBlockEl;
|
|
1639
2165
|
closeGutterMenu();
|
|
1640
2166
|
deleteBlockViaGutter(blockEl);
|
|
1641
|
-
});
|
|
2167
|
+
}, 'trash');
|
|
1642
2168
|
|
|
1643
2169
|
gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
|
|
1644
2170
|
e.stopPropagation();
|
|
1645
2171
|
const blockEl = gutterMenuBlockEl;
|
|
1646
2172
|
closeGutterMenu();
|
|
1647
2173
|
openRawViaGutter(blockEl);
|
|
1648
|
-
});
|
|
2174
|
+
}, 'code');
|
|
1649
2175
|
|
|
1650
2176
|
return el;
|
|
1651
2177
|
}
|
|
@@ -1657,7 +2183,7 @@
|
|
|
1657
2183
|
// `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
|
|
1658
2184
|
// no viewport arithmetic, and the panel travels with the menu when the menu
|
|
1659
2185
|
// is moved into another block.
|
|
1660
|
-
function openConvertSubmenu(anchorBtn) {
|
|
2186
|
+
function openConvertSubmenu(anchorBtn, viaHover) {
|
|
1661
2187
|
closeConvertSubmenu();
|
|
1662
2188
|
const sub = document.createElement('div');
|
|
1663
2189
|
sub.className = 'ed-handle-menu ed-handle-submenu';
|
|
@@ -1678,9 +2204,22 @@
|
|
|
1678
2204
|
sub.style.top = anchorBtn.offsetTop + 'px';
|
|
1679
2205
|
anchorBtn.parentNode.appendChild(sub);
|
|
1680
2206
|
convertSubmenu = sub;
|
|
2207
|
+
convertSubmenuViaHover = !!viaHover;
|
|
2208
|
+
// The aim triangle needs a previous sample to have an apex; the first
|
|
2209
|
+
// mousemove after the open only records one. Cleared here so a panel
|
|
2210
|
+
// re-opened on another block cannot aim from the old block's geometry.
|
|
2211
|
+
submenuAimPrev = null;
|
|
2212
|
+
// Attached only while a panel is standing, and removed again below — a
|
|
2213
|
+
// document-level mousemove listener that outlived the panel would be a
|
|
2214
|
+
// per-move cost on every page for nothing.
|
|
2215
|
+
document.addEventListener('mousemove', onSubmenuPointerMove);
|
|
1681
2216
|
}
|
|
1682
2217
|
|
|
1683
2218
|
function closeConvertSubmenu() {
|
|
2219
|
+
cancelSubmenuClose();
|
|
2220
|
+
submenuAimPrev = null;
|
|
2221
|
+
convertSubmenuViaHover = false;
|
|
2222
|
+
document.removeEventListener('mousemove', onSubmenuPointerMove);
|
|
1684
2223
|
if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
|
|
1685
2224
|
}
|
|
1686
2225
|
|
|
@@ -1696,6 +2235,46 @@
|
|
|
1696
2235
|
gutterMenuBlockEl = null;
|
|
1697
2236
|
}
|
|
1698
2237
|
|
|
2238
|
+
// ── The one rule about 轉換成 and block type ────────────────────────────
|
|
2239
|
+
// The block types 轉換成 is WITHHELD from, and the banner the BATCH path
|
|
2240
|
+
// shows when a span holds one. ONE table, TWO call sites — toggleGutterMenu()
|
|
2241
|
+
// just below hides the item on a single block's ⠿; convertBlockViaMenu()
|
|
2242
|
+
// refuses a span that holds one — and that is the point of it being a shared
|
|
2243
|
+
// predicate rather than a type test written out twice. MEASURED 2026-08-31,
|
|
2244
|
+
// with the batch path gating only 'table': a selection over a paragraph, an
|
|
2245
|
+
// hr and a paragraph, converted from a grip on a paragraph, wrote
|
|
2246
|
+
// '# Doc\n\n- alpha\n- ---\n- bravo\n' with NO banner — '- ---' being the
|
|
2247
|
+
// exact byte sequence the 'hr' reason below names as why the item is not
|
|
2248
|
+
// offered. A type withheld in one place and silently allowed in the other IS
|
|
2249
|
+
// the defect; deriving both from here is what makes a fourth type impossible
|
|
2250
|
+
// to add to one affordance and forget in the other.
|
|
2251
|
+
//
|
|
2252
|
+
// Why these three:
|
|
2253
|
+
// 'table' — §7: there is no target that could carry a table's cells, and
|
|
2254
|
+
// every one of the twelve would destroy them.
|
|
2255
|
+
// 'hr' / 'html' — the gesture would LIE. convert-md strips a block's
|
|
2256
|
+
// MARKER to get its content, and an <hr> has no content: its source line
|
|
2257
|
+
// IS the marker. Measured: 'hr' → 項目符號列表 writes '- ---', which
|
|
2258
|
+
// marked re-lexes as an hr again, so the file's bytes change, the block
|
|
2259
|
+
// type does not, and nothing is said; 'hr' → 文字 is a byte no-op, also
|
|
2260
|
+
// silent. An 'html' block is raw passthrough for the same reason — no
|
|
2261
|
+
// marker to strip and nothing to re-host. Nothing is LOST either way,
|
|
2262
|
+
// but an item that appears to work and does nothing is worse than an
|
|
2263
|
+
// item that is not offered.
|
|
2264
|
+
//
|
|
2265
|
+
// Each type names ITSELF in the banner: a user whose selection holds an hr
|
|
2266
|
+
// must not be told it holds a table. One template, three labels — the
|
|
2267
|
+
// wording is per type, the RULE is not.
|
|
2268
|
+
const BATCH_CONVERT_WITHHELD_MESSAGES = {
|
|
2269
|
+
table: '選取範圍含有表格,無法整批轉換',
|
|
2270
|
+
hr: '選取範圍含有分隔線,無法整批轉換',
|
|
2271
|
+
html: '選取範圍含有 HTML 區塊,無法整批轉換',
|
|
2272
|
+
};
|
|
2273
|
+
function convertWithheldFor(blockType) {
|
|
2274
|
+
return Object.prototype.hasOwnProperty.call(
|
|
2275
|
+
BATCH_CONVERT_WITHHELD_MESSAGES, blockType);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
1699
2278
|
function toggleGutterMenu(blockEl) {
|
|
1700
2279
|
if (!blockEl) return;
|
|
1701
2280
|
if (gutterMenuBlockEl === blockEl) { closeGutterMenu(); return; }
|
|
@@ -1712,21 +2291,10 @@
|
|
|
1712
2291
|
closeConvertSubmenu();
|
|
1713
2292
|
gutterMenuBlockEl = blockEl;
|
|
1714
2293
|
const blockType = blockEl.getAttribute('data-block-type');
|
|
1715
|
-
// Spec §7
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
|
|
1719
|
-
// 'hr' and 'html' are withheld for a different, measured reason: the
|
|
1720
|
-
// gesture would LIE. convert-md strips a block's marker to get its
|
|
1721
|
-
// content, and an <hr> has no content — its source line IS the marker.
|
|
1722
|
-
// Measured: 'hr' → 項目符號列表 writes '- ---', which marked re-lexes
|
|
1723
|
-
// as an hr again, so the file's bytes change, the block type does not,
|
|
1724
|
-
// and no banner is shown. 'hr' → 文字 is a byte no-op, also silent.
|
|
1725
|
-
// An 'html' block is raw passthrough for the same reason: there is no
|
|
1726
|
-
// marker to strip and no content to re-host. Nothing is lost either way,
|
|
1727
|
-
// but an item that appears to work and does nothing is worse than an
|
|
1728
|
-
// item that is not offered.
|
|
1729
|
-
gutterMenuConvert.hidden = (blockType === 'table' || blockType === 'hr' || blockType === 'html');
|
|
2294
|
+
// Spec §7 / §3.7. Which types, and why, is stated ONCE just above — this
|
|
2295
|
+
// is one of that predicate's two call sites, and convertBlockViaMenu()'s
|
|
2296
|
+
// batch refusal is the other.
|
|
2297
|
+
gutterMenuConvert.hidden = convertWithheldFor(blockType);
|
|
1730
2298
|
gutterMenuDuplicate.hidden = false;
|
|
1731
2299
|
gutterMenuDelete.hidden = false;
|
|
1732
2300
|
// RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
|
|
@@ -1738,7 +2306,28 @@
|
|
|
1738
2306
|
// between blocks, so this must be reset on every open, not set once.
|
|
1739
2307
|
// (test/editor-reader-rebind.test.js drives raw-edit through this button
|
|
1740
2308
|
// by its exact text on a paragraph.)
|
|
1741
|
-
|
|
2309
|
+
//
|
|
2310
|
+
// §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
|
|
2311
|
+
// is the half S3 shipped without. openRawViaGutter() rewrites ONE block's
|
|
2312
|
+
// source lines, so over a set of N it silently answers for the grip's
|
|
2313
|
+
// block and ignores the rest: an item that appears to act on the selection
|
|
2314
|
+
// and does not.
|
|
2315
|
+
//
|
|
2316
|
+
// The question is §3.3's MEMBERSHIP question, asked exactly the way every
|
|
2317
|
+
// set operation asks it (resolveGutterOperands() → resolveMembership(),
|
|
2318
|
+
// identifying the grip block by REFERENCE into `blocks` — Task 1 carry 4),
|
|
2319
|
+
// and not "is anything selected". Two states answer differently and both
|
|
2320
|
+
// keep the item: a set of exactly the grip block is a set of ONE, which
|
|
2321
|
+
// raw-edits fine; and a set standing elsewhere in the document is not this
|
|
2322
|
+
// grip's set at all — §3.3 turns that gesture into a single-block operation
|
|
2323
|
+
// before it acts, so the item is right there too.
|
|
2324
|
+
const mdRec = blockRecOf(blockEl);
|
|
2325
|
+
const mdMembership = (selectionLib && mdRec)
|
|
2326
|
+
? selectionLib.resolveMembership(blockSelection, blocks, mdRec)
|
|
2327
|
+
: { mode: 'single', members: mdRec ? [mdRec] : [] };
|
|
2328
|
+
const mdMultiSelected =
|
|
2329
|
+
mdMembership.mode === 'batch' && mdMembership.members.length > 1;
|
|
2330
|
+
gutterMenuMd.hidden = (blockType === 'li') || mdMultiSelected;
|
|
1742
2331
|
blockEl.appendChild(gutterMenu);
|
|
1743
2332
|
}
|
|
1744
2333
|
|
|
@@ -2165,97 +2754,365 @@
|
|
|
2165
2754
|
// li -> heading would hit the degrade path and refuse itself. Reading `lines`
|
|
2166
2755
|
// also means the inline content is never re-serialized, so escapeText() never
|
|
2167
2756
|
// runs over it and a `~5px` in the converted block stays `~5px`.
|
|
2168
|
-
|
|
2169
|
-
|
|
2757
|
+
// ── S3 Task 6: the ⠿ gesture's OPERAND SET (spec §3.3) ──────────────────
|
|
2758
|
+
//
|
|
2759
|
+
// Up to S2 every gutter operation worked on exactly one block and the three
|
|
2760
|
+
// entry points below carried a byte-identical preamble: capture identity,
|
|
2761
|
+
// resolve whatever session is open, re-find the block if that commit
|
|
2762
|
+
// re-rendered, refuse a block that owns no source line. §3.3 turns that ONE
|
|
2763
|
+
// element into a SPAN — 「grip 在選取集合內 → 作用整個集合;grip 在集合外 →
|
|
2764
|
+
// 先把集合換成該單一 block 再作用」— so the preamble moved here and grew a
|
|
2765
|
+
// membership step.
|
|
2766
|
+
//
|
|
2767
|
+
// ORDERING IS LOAD-BEARING. Membership is resolved AFTER switchAwayFrom(),
|
|
2768
|
+
// never before: that call can commit an open burst, and its rerenderAll()
|
|
2769
|
+
// renumbers every block id and hands back a fresh `blocks` array. A record
|
|
2770
|
+
// captured ahead of it is a dangling reference — and resolveMembership()
|
|
2771
|
+
// compares by REFERENCE (Task 1 carry 4: ids are forbidden architecturally
|
|
2772
|
+
// and line tuples are genuinely ambiguous, since `- - - a` yields two
|
|
2773
|
+
// structurally identical phantoms {startLine:1, endLine:0}), so a stale
|
|
2774
|
+
// record silently answers 'single' and the batch degrades to one block. The
|
|
2775
|
+
// selection itself survives that render for free — its identity is a LINE
|
|
2776
|
+
// RANGE, which is exactly what no render can invalidate.
|
|
2777
|
+
//
|
|
2778
|
+
// Returns `null` when the gesture is refused or dropped — the banner has
|
|
2779
|
+
// already been raised — otherwise `{ els, recs, batch }`: the LIVE block
|
|
2780
|
+
// elements in document order, their records out of `blocks` (by reference),
|
|
2781
|
+
// and whether more than one block is being operated on.
|
|
2782
|
+
async function resolveGutterOperands(blockEl) {
|
|
2783
|
+
if (!blockEl) return null;
|
|
2784
|
+
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
2785
|
+
// alive across the ⠿ press, so the commit that lands inside
|
|
2786
|
+
// switchAwayFrom() below can be a rewrite of the very block the gesture
|
|
2787
|
+
// names — in which case reresolveBlockEl()'s SOURCE fingerprint is
|
|
2788
|
+
// guaranteed to miss, because WE are the reason the source changed. That
|
|
2789
|
+
// is not a dropped gesture; startLine + type still name the block, and the
|
|
2790
|
+
// fingerprint's job (proving an UNRELATED commit did not move somebody
|
|
2791
|
+
// else into this slot) is done by those two here.
|
|
2170
2792
|
const identity = captureBlockIdentity(blockEl);
|
|
2171
|
-
// Whether the session switchAwayFrom() is about to resolve belongs to THIS
|
|
2172
|
-
// block. Finding 5a's mousedown preventDefault() deliberately keeps a dirty
|
|
2173
|
-
// burst alive across the ⠿ press, so the commit that lands inside
|
|
2174
|
-
// switchAwayFrom() below can be a rewrite of the very block we are about to
|
|
2175
|
-
// convert — in which case reresolveBlockEl()'s source fingerprint is
|
|
2176
|
-
// guaranteed to miss, because WE are the reason the source changed. That is
|
|
2177
|
-
// not a dropped gesture; startLine + type still name the block, and the
|
|
2178
|
-
// fingerprint's job (proving an UNRELATED commit did not move somebody else
|
|
2179
|
-
// into this slot) is done by those two here.
|
|
2180
2793
|
const selfSession = ownsOpenSession(blockEl);
|
|
2181
2794
|
const ok = await switchAwayFrom();
|
|
2182
|
-
if (!ok) return;
|
|
2795
|
+
if (!ok) return null;
|
|
2183
2796
|
let liveBlockEl = blockEl;
|
|
2184
2797
|
if (!document.body.contains(blockEl)) {
|
|
2185
2798
|
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2186
2799
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2187
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2800
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2801
|
+
}
|
|
2802
|
+
// A block that owns no source line has an INVERTED range
|
|
2803
|
+
// (endLine === startLine - 1) and every commit helper handed one does
|
|
2804
|
+
// something plausible and wrong.
|
|
2805
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
2806
|
+
const rec = blockRecOf(liveBlockEl);
|
|
2807
|
+
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2808
|
+
|
|
2809
|
+
const res = selectionLib
|
|
2810
|
+
? selectionLib.resolveMembership(blockSelection, blocks, rec)
|
|
2811
|
+
: { mode: 'single', members: [rec] };
|
|
2812
|
+
if (res.mode !== 'batch') {
|
|
2813
|
+
// §3.3's second half: 「grip 在集合外 → 先把集合換成該單一 block 再作用」.
|
|
2814
|
+
// Done here rather than left to the post-operation collapse so a REFUSAL
|
|
2815
|
+
// also leaves the documented state behind, instead of a set somewhere
|
|
2816
|
+
// else in the document that the user's next keystroke would batch over.
|
|
2817
|
+
// No selection standing at all stays exactly as it was pre-S3.
|
|
2818
|
+
if (blockSelection) {
|
|
2819
|
+
setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
|
|
2820
|
+
}
|
|
2821
|
+
return { els: [liveBlockEl], recs: [rec], batch: false };
|
|
2822
|
+
}
|
|
2823
|
+
const members = res.members;
|
|
2824
|
+
// Task 1 carry 6: `spanIsContiguous([])` is TRUE — no members, no gaps — so
|
|
2825
|
+
// emptiness is checked separately and gets its own wording. Unreachable
|
|
2826
|
+
// through the menu today (resolveMembership() only answers 'batch' when the
|
|
2827
|
+
// grip block is itself a member), but the two states are genuinely
|
|
2828
|
+
// different and a single gate would report the wrong one if it ever is.
|
|
2829
|
+
if (!members.length) { refuseStructuralListEdit(BATCH_EMPTY_MESSAGE); return null; }
|
|
2830
|
+
// Task 1 carry 2: a gap is NOT only what a disjoint selection produces. A
|
|
2831
|
+
// no-line phantom can sit BETWEEN two real members — `- a\n- - b\n- c\n`
|
|
2832
|
+
// yields li{1,1} | phantom{2,1} | li{2,2} | li{3,3}, so selecting lines 1–2
|
|
2833
|
+
// (an entirely natural gesture) lands on indices 0 and 2. The batch cannot
|
|
2834
|
+
// be expressed as one contiguous index range, so it refuses rather than
|
|
2835
|
+
// writing a range it cannot honour.
|
|
2836
|
+
if (!selectionLib.spanIsContiguous(members, blocks)) {
|
|
2837
|
+
refuseStructuralListEdit(BATCH_GAP_MESSAGE);
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
const els = [];
|
|
2841
|
+
for (let i = 0; i < members.length; i++) {
|
|
2842
|
+
const el = blockElById(members[i].id);
|
|
2843
|
+
if (!el) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2844
|
+
// Belt to membersOf()'s own braces: selection.js excludes a block that
|
|
2845
|
+
// owns no line from every member set it builds, so this is unreachable —
|
|
2846
|
+
// but "another module filters it" is an argument about another file's
|
|
2847
|
+
// output, and this is the guard that stands between an inverted range and
|
|
2848
|
+
// a commit helper.
|
|
2849
|
+
if (blockOwnsNoLine(el)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
2850
|
+
els.push(el);
|
|
2851
|
+
}
|
|
2852
|
+
return { els: els, recs: members, batch: members.length > 1 };
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// §3.3's collapse: 「操作後集合塌縮為「操作結果所涵蓋的行區間」」. Declared
|
|
2856
|
+
// IMMEDIATELY before the render and never earlier — rerenderAll() consumes the
|
|
2857
|
+
// declaration at its very top, before its first failure exit, so one left
|
|
2858
|
+
// standing lands on some LATER, unrelated render (Task 5 carry 5).
|
|
2859
|
+
//
|
|
2860
|
+
// ONLY when a set was actually standing, and that guard is load-bearing:
|
|
2861
|
+
// without it an ordinary single-block ⠿ 建立副本 or 轉換成 on a document with
|
|
2862
|
+
// NO selection would CREATE one — collapseTo() resolves the declared range,
|
|
2863
|
+
// membersOf() finds the block, and setBlockSelection() tints it and pulls the
|
|
2864
|
+
// roving focus onto it. That is a selection the user never made, and it would
|
|
2865
|
+
// change the resting state of every pre-S3 gutter gesture. resolveGutterOperands()
|
|
2866
|
+
// has already applied §3.3's 「grip 在集合外 → 先把集合換成該單一 block」, so
|
|
2867
|
+
// `blockSelection` is non-null here exactly when the gesture was a set
|
|
2868
|
+
// operation. Leaving the declaration UNSET is the correct answer otherwise:
|
|
2869
|
+
// `undefined` means "keep whatever is standing", and nothing is.
|
|
2870
|
+
function declareCollapse(range) {
|
|
2871
|
+
if (blockSelection) declareSelectionRange(range);
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
|
|
2875
|
+
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
2876
|
+
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
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 = '落點的子項目會失去上層項目,無法搬移到這裡';
|
|
2920
|
+
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
2921
|
+
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
2922
|
+
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
2923
|
+
// toggleGutterMenu(), where the menu-hiding call site can share them. The
|
|
2924
|
+
// four constants above are shape-level and belong to every batch operation.
|
|
2925
|
+
|
|
2926
|
+
function blockElById(id) {
|
|
2927
|
+
return document.querySelector('.ed-block[data-block-id="' + id + '"]');
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// §3.4 rule 3's batch anchor: 「多 block 操作的錨點 = 選取集合中最小的舊
|
|
2931
|
+
// indent」, not the first member. Deleting `{a(0), b(1)}` anchored on a(0) is
|
|
2932
|
+
// right, but anchored on the FIRST member of `{b(1), …}` the following
|
|
2933
|
+
// segment head's bound is computed against 1 instead of 0 and a same-segment
|
|
2934
|
+
// sibling lands at −1 (undefined in columns).
|
|
2935
|
+
//
|
|
2936
|
+
// ⚠ This value is the anchor for the blocks BELOW the operated set — the
|
|
2937
|
+
// bound applyIndentClamp() re-measures them against — and NOTHING else.
|
|
2938
|
+
// §3.4 rule 3 also cites a batch TAB whose first member is at its ceiling as
|
|
2939
|
+
// a failure of first-member anchoring, but that half was superseded by the
|
|
2940
|
+
// 2026-08-31 D1 review: how far the set itself may move is batchIndentDelta()
|
|
2941
|
+
// and is the minimum head-room across ALL members, so a set holding a member
|
|
2942
|
+
// at its ceiling is a whole-batch no-op regardless of what its shallowest
|
|
2943
|
+
// member could have done alone. Do not re-derive Tab's delta from here.
|
|
2944
|
+
function spanMinIndent(els) {
|
|
2945
|
+
let min = null;
|
|
2946
|
+
(els || []).forEach((el) => {
|
|
2947
|
+
const v = Number(el.getAttribute('data-indent')) || 0;
|
|
2948
|
+
if (min === null || v < min) min = v;
|
|
2949
|
+
});
|
|
2950
|
+
return min === null ? 0 : min;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
// The kinds a span holds, and the one shape no batch path can express.
|
|
2954
|
+
// A contiguous span of list items is rewritten through its RUN's
|
|
2955
|
+
// re-serialization; a contiguous span of non-list blocks is a plain line
|
|
2956
|
+
// splice. A span holding BOTH is neither: the run's survivors have to be
|
|
2957
|
+
// re-emitted at the same time as a line range outside the run is removed, and
|
|
2958
|
+
// the blank-line policy at the seam between them has no ruling in the spec.
|
|
2959
|
+
// Refused with its own banner rather than guessed at.
|
|
2960
|
+
function spanListKinds(els) {
|
|
2961
|
+
const kinds = els.map((el) => el.getAttribute('data-block-type'));
|
|
2962
|
+
return {
|
|
2963
|
+
kinds: kinds,
|
|
2964
|
+
allLi: kinds.length > 0 && kinds.every((k) => k === 'li'),
|
|
2965
|
+
anyLi: kinds.indexOf('li') !== -1,
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
// Every list batch is one run's problem: listRunOf() is the span the commit
|
|
2970
|
+
// re-serializes, and a member outside it would be rewritten by a range that
|
|
2971
|
+
// does not cover it. Contiguity in `blocks` does not imply one run — two
|
|
2972
|
+
// adjacent runs separated by a delimiter change (`- a` / `* b`) are adjacent
|
|
2973
|
+
// blocks with no phantom between them.
|
|
2974
|
+
function batchRunOf(liEls) {
|
|
2975
|
+
const run = listRunOf(liEls[0]);
|
|
2976
|
+
if (!run.length) return null;
|
|
2977
|
+
for (let i = 0; i < liEls.length; i++) if (run.indexOf(liEls[i]) === -1) return null;
|
|
2978
|
+
return run;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
async function convertBlockViaMenu(blockEl, target) {
|
|
2982
|
+
if (!blockEl || !target) return;
|
|
2983
|
+
// §3.3's membership rules and the whole shared preamble — see
|
|
2984
|
+
// resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
|
|
2985
|
+
// central constraint is that every path below takes that span whole rather
|
|
2986
|
+
// than looping, because a loop re-renders between items and invalidates
|
|
2987
|
+
// every id in between (the defect class recorded in this file twice: a
|
|
2988
|
+
// fenced block raw-edited into two paragraphs changes the BLOCK count
|
|
2989
|
+
// without changing the LINE count, so an id-indexed delete hit the wrong
|
|
2990
|
+
// block, and the same shape silently rewrote a neighbouring table).
|
|
2991
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
2992
|
+
if (!operands) return;
|
|
2993
|
+
const els = operands.els;
|
|
2994
|
+
const recs = operands.recs;
|
|
2995
|
+
const shape = spanListKinds(els);
|
|
2996
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
2997
|
+
// §3.7 / §7 withhold 轉換成 from some block types ENTIRELY —
|
|
2998
|
+
// toggleGutterMenu() hides the item outright, and the list plus every
|
|
2999
|
+
// reason is in BATCH_CONVERT_WITHHELD_MESSAGES above it. This is the SAME
|
|
3000
|
+
// predicate, asked of a SET: none of those reasons stops applying because
|
|
3001
|
+
// the block happens to be one member of a span, and a batch whose grip is a
|
|
3002
|
+
// paragraph reaches the withheld member anyway. MEASURED, before this gate
|
|
3003
|
+
// existed: a table span wrote '> | A | B |' … / '- | A | B |' with its other
|
|
3004
|
+
// rows as continuations, an hr span wrote '- ---', an html span wrote
|
|
3005
|
+
// '> <div>x</div>'. Nothing is LOST in any of them (they all still lex as
|
|
3006
|
+
// what they were) — which is exactly why it is so quiet: the single-block ⠿
|
|
3007
|
+
// and the batch ⠿ give OPPOSITE answers to the same question with nothing
|
|
3008
|
+
// on screen saying so, the shape §3.6's 2026-08-31 「不得另寫一條」 ruling
|
|
3009
|
+
// exists to prevent. ⇒ REFUSE, with a banner (§3.6: 靜默不動作是缺陷), file
|
|
3010
|
+
// byte-identical.
|
|
3011
|
+
//
|
|
3012
|
+
// The banner names the type that is ACTUALLY in the span, not one of the
|
|
3013
|
+
// four shape-level messages above: BATCH_MIXED names list items vs other
|
|
3014
|
+
// blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
|
|
3015
|
+
// names non-contiguity — either would name something the user's selection
|
|
3016
|
+
// has not done. So would telling a user with an hr in the selection that it
|
|
3017
|
+
// contains a table.
|
|
3018
|
+
//
|
|
3019
|
+
// SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
|
|
3020
|
+
// holding any of these types are measured correct and have no equivalent
|
|
3021
|
+
// objection — they do not need a target that can carry cells, or a marker
|
|
3022
|
+
// to strip — so the predicate deliberately does NOT reach
|
|
3023
|
+
// resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
|
|
3024
|
+
// those five cells as APPLYING so a widening shows up as a failure too.
|
|
3025
|
+
// Placed BELOW the mixed gate so no span that already refuses changes which
|
|
3026
|
+
// banner it gets.
|
|
3027
|
+
const withheld = shape.kinds.filter(convertWithheldFor)[0];
|
|
3028
|
+
if (withheld) {
|
|
3029
|
+
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
|
|
3030
|
+
return;
|
|
2188
3031
|
}
|
|
2189
|
-
// Same refusal deleteBlockViaGutter() makes, for the same reason: a block
|
|
2190
|
-
// that owns no source line has an INVERTED range (endLine === startLine-1),
|
|
2191
|
-
// and every commit helper handed one does something plausible and wrong.
|
|
2192
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2193
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2194
|
-
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2195
|
-
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2196
|
-
const kind = liveBlockEl.getAttribute('data-block-type');
|
|
2197
3032
|
|
|
2198
3033
|
// §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
|
|
2199
3034
|
// listRunSupportsStructuralEdit() BEFORE any mutation, the same door
|
|
2200
3035
|
// Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
|
|
2201
3036
|
// is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2202
|
-
// descendant of its members) — see
|
|
3037
|
+
// descendant of its members) — see deleteListItemsViaGutter()'s own note.
|
|
2203
3038
|
//
|
|
2204
3039
|
// ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
3040
|
+
// refusals below and, further down, of stripMarker(): a multi-line li must
|
|
3041
|
+
// report §4.1's 「此清單含不支援的格式,無法調整結構」 and not
|
|
3042
|
+
// convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
|
|
3043
|
+
// would get if stripMarker() saw it first (that function refuses a
|
|
2209
3044
|
// multi-line li too, for its own, narrower reason). The runtime scenario
|
|
2210
|
-
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so
|
|
2211
|
-
//
|
|
3045
|
+
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
|
|
3046
|
+
// is what notices if this order is ever flipped.
|
|
2212
3047
|
//
|
|
2213
|
-
// A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's
|
|
2214
|
-
//
|
|
2215
|
-
// remaining a perfectly good bystander.
|
|
3048
|
+
// A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's own
|
|
3049
|
+
// text or line count, so a multi-line li refuses as a TARGET while
|
|
3050
|
+
// remaining a perfectly good bystander. S3 Task 6 passes the WHOLE operand
|
|
3051
|
+
// set as the target list — every member is being rewritten, so every member
|
|
3052
|
+
// has to clear the gate, not just the one the ⠿ was pressed on.
|
|
2216
3053
|
let liRun = null;
|
|
2217
|
-
if (
|
|
2218
|
-
liRun =
|
|
2219
|
-
if (!liRun
|
|
2220
|
-
if (!listRunSupportsStructuralEdit(liRun,
|
|
3054
|
+
if (shape.allLi) {
|
|
3055
|
+
liRun = batchRunOf(els);
|
|
3056
|
+
if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
3057
|
+
if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
|
|
2221
3058
|
}
|
|
2222
3059
|
|
|
2223
|
-
// S2 Task 3: li → a LIST target. The
|
|
2224
|
-
// run and the existing re-serialization machinery applies
|
|
2225
|
-
|
|
2226
|
-
|
|
3060
|
+
// S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
|
|
3061
|
+
// the run stays a run and the existing re-serialization machinery applies
|
|
3062
|
+
// unchanged.
|
|
3063
|
+
if (shape.allLi && convertMd.targetIsList(target)) {
|
|
3064
|
+
await convertListItemsType(els, liRun, recs, target);
|
|
2227
3065
|
return;
|
|
2228
3066
|
}
|
|
2229
|
-
// S2 Task 4: li → a NON-list target. The
|
|
2230
|
-
// has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
2231
|
-
// between them — the plain path below would leave the converted
|
|
2232
|
-
// mid-list with no separator and lazy continuation would swallow
|
|
2233
|
-
// the item above (measured, §4.3 rule 1).
|
|
2234
|
-
if (
|
|
2235
|
-
await
|
|
3067
|
+
// S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
|
|
3068
|
+
// so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
3069
|
+
// lines put between them — the plain path below would leave the converted
|
|
3070
|
+
// lines mid-list with no separator and lazy continuation would swallow them
|
|
3071
|
+
// into the item above (measured, §4.3 rule 1).
|
|
3072
|
+
if (shape.allLi) {
|
|
3073
|
+
await convertListItemsAway(els, liRun, recs, target);
|
|
2236
3074
|
return;
|
|
2237
3075
|
}
|
|
2238
|
-
// S2 Task 5:
|
|
2239
|
-
// policy applies — eat the separator to an adjacent run of
|
|
2240
|
-
// type, or the merged list goes LOOSE and every item of it
|
|
2241
|
-
// read-only. Same rule, same helper the li → li path above uses.
|
|
3076
|
+
// S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
|
|
3077
|
+
// 2's looseness policy applies — eat the separator to an adjacent run of
|
|
3078
|
+
// the same list type, or the merged list goes LOOSE and every item of it
|
|
3079
|
+
// degrades read-only. Same rule, same helper the li → li path above uses.
|
|
2242
3080
|
if (convertMd.targetIsList(target)) {
|
|
2243
|
-
await
|
|
3081
|
+
await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
2244
3082
|
return;
|
|
2245
3083
|
}
|
|
2246
3084
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
3085
|
+
// The plain path: N non-list blocks to a non-list target. One commit over
|
|
3086
|
+
// the span's whole line range, each member converted from its OWN source
|
|
3087
|
+
// lines (never re-serialized, which is what keeps a `~5px` a `~5px`) and
|
|
3088
|
+
// the pieces separated by a blank line so they re-lex as N blocks.
|
|
3089
|
+
const first = recs[0];
|
|
3090
|
+
const last = recs[recs.length - 1];
|
|
3091
|
+
const pieces = [];
|
|
3092
|
+
for (let i = 0; i < recs.length; i++) {
|
|
3093
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
3094
|
+
const stripped = convertMd.stripMarker(src, shape.kinds[i]);
|
|
3095
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
3096
|
+
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
3097
|
+
}
|
|
3098
|
+
const md = pieces.join('\n\n');
|
|
2251
3099
|
|
|
2252
3100
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2253
|
-
|
|
3101
|
+
first.startLine, last.endLine, md);
|
|
2254
3102
|
// Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
|
|
2255
|
-
// onto the undo stack either, so there is nothing to render or roll back
|
|
3103
|
+
// onto the undo stack either, so there is nothing to render or roll back,
|
|
3104
|
+
// and no selection range to declare.
|
|
2256
3105
|
if (result.op === null) return;
|
|
2257
3106
|
const prevLines = lines;
|
|
2258
3107
|
lines = result.lines;
|
|
3108
|
+
// §3.3: 「操作後集合塌縮為操作結果所涵蓋的行區間」. Declared IMMEDIATELY
|
|
3109
|
+
// before the render, never earlier (Task 5 carry 5): rerenderAll() consumes
|
|
3110
|
+
// the declaration at its very top, so one left standing lands on some
|
|
3111
|
+
// later, unrelated render.
|
|
3112
|
+
declareCollapse({
|
|
3113
|
+
startLine: first.startLine,
|
|
3114
|
+
endLine: first.startLine + md.split('\n').length - 1,
|
|
3115
|
+
});
|
|
2259
3116
|
const okRender = await safeRerenderAll();
|
|
2260
3117
|
if (!okRender) {
|
|
2261
3118
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
@@ -2310,7 +3167,7 @@
|
|
|
2310
3167
|
// them (list-md.js:462) and the WHOLE run degrades read-only with no
|
|
2311
3168
|
// banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
|
|
2312
3169
|
// conversion. A li therefore duplicates through its own RUN's
|
|
2313
|
-
// re-serialization (
|
|
3170
|
+
// re-serialization (duplicateListItems() below), which emits no blank at
|
|
2314
3171
|
// all and re-runs §3.8's renumbering on the way.
|
|
2315
3172
|
//
|
|
2316
3173
|
// Neither path re-serializes the copy's CONTENT: the non-li path slices
|
|
@@ -2318,43 +3175,39 @@
|
|
|
2318
3175
|
// under the ORIGINAL's block id, so list-md.js replays the file's own bytes
|
|
2319
3176
|
// for it and only re-states the marker. Both keep a `~5px` a `~5px`.
|
|
2320
3177
|
async function duplicateBlockViaMenu(blockEl) {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
if (
|
|
2333
|
-
|
|
2334
|
-
if (!document.body.contains(blockEl)) {
|
|
2335
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2336
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2337
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2338
|
-
}
|
|
2339
|
-
// Same refusal deleteBlockViaGutter() and convertBlockViaMenu() make, for
|
|
2340
|
-
// the same reason: a block that owns no source line has an INVERTED range
|
|
2341
|
-
// (endLine === startLine - 1) and every commit helper handed one does
|
|
2342
|
-
// something plausible and wrong.
|
|
2343
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2344
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2345
|
-
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2346
|
-
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2347
|
-
|
|
2348
|
-
if (liveBlockEl.getAttribute('data-block-type') === 'li') {
|
|
2349
|
-
await duplicateListItem(liveBlockEl);
|
|
3178
|
+
// §3.3's membership rules plus the shared preamble — see
|
|
3179
|
+
// resolveGutterOperands(). The whole span is duplicated by ONE commit; a
|
|
3180
|
+
// per-member loop would re-render between items and invalidate every id in
|
|
3181
|
+
// between.
|
|
3182
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
3183
|
+
if (!operands) return;
|
|
3184
|
+
const els = operands.els;
|
|
3185
|
+
const recs = operands.recs;
|
|
3186
|
+
const shape = spanListKinds(els);
|
|
3187
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
3188
|
+
|
|
3189
|
+
if (shape.allLi) {
|
|
3190
|
+
await duplicateListItems(els, recs);
|
|
2350
3191
|
return;
|
|
2351
3192
|
}
|
|
2352
3193
|
|
|
2353
|
-
|
|
2354
|
-
|
|
3194
|
+
// The non-li span duplicates as ONE slice of `lines` — separators between
|
|
3195
|
+
// its members included, which is what makes the copies re-lex as the same N
|
|
3196
|
+
// blocks rather than one merged one. commitBlockInsertion() anchors on the
|
|
3197
|
+
// LAST member (it inserts BELOW its anchor), and its own leading blank is
|
|
3198
|
+
// the separator between the original span and the copy.
|
|
3199
|
+
const first = recs[0];
|
|
3200
|
+
const last = recs[recs.length - 1];
|
|
3201
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, last.id,
|
|
3202
|
+
lines.slice(first.startLine - 1, last.endLine));
|
|
2355
3203
|
if (result.op === null) return;
|
|
2356
3204
|
const prevLines = lines;
|
|
2357
3205
|
lines = result.lines;
|
|
3206
|
+
// §3.3's collapse. A duplicate inserts BELOW everything it copied, so the
|
|
3207
|
+
// originals keep the exact line range they had — declaring it is the same
|
|
3208
|
+
// answer as "keep what is standing" and says so explicitly, which is what
|
|
3209
|
+
// keeps this correct if the insertion point ever moves.
|
|
3210
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
2358
3211
|
if (!(await safeRerenderAll())) {
|
|
2359
3212
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2360
3213
|
}
|
|
@@ -2366,16 +3219,18 @@
|
|
|
2366
3219
|
// §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
|
|
2367
3220
|
// gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
|
|
2368
3221
|
// bravo').
|
|
2369
|
-
async function
|
|
2370
|
-
|
|
2371
|
-
|
|
3222
|
+
async function duplicateListItems(liEls, recs) {
|
|
3223
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
3224
|
+
const run = batchRunOf(liEls);
|
|
3225
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2372
3226
|
// §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
|
|
2373
3227
|
// themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
|
|
2374
3228
|
// which is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2375
3229
|
// descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
|
|
2376
3230
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
2377
|
-
// TARGET while remaining a perfectly good bystander
|
|
2378
|
-
|
|
3231
|
+
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
3232
|
+
// member is a target.
|
|
3233
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
2379
3234
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
2380
3235
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
2381
3236
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
@@ -2388,22 +3243,29 @@
|
|
|
2388
3243
|
// subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
|
|
2389
3244
|
// following blocks at a STRICTLY greater indent — and listRunOf() already
|
|
2390
3245
|
// covers every one of them, so the insertion point is always inside `run`.
|
|
2391
|
-
|
|
2392
|
-
|
|
3246
|
+
// For a BATCH the anchor is the LAST member's subtree, which is the direct
|
|
3247
|
+
// generalization: the copies land after everything the set owns, in the
|
|
3248
|
+
// set's own document order.
|
|
3249
|
+
const anchorLi = liEls[liEls.length - 1];
|
|
3250
|
+
const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
|
|
3251
|
+
const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
|
|
2393
3252
|
const at = run.indexOf(lastEl);
|
|
2394
3253
|
if (at < 0) return;
|
|
2395
3254
|
|
|
2396
|
-
const
|
|
3255
|
+
const copies = liEls.map((liEl) => liEl.cloneNode(true));
|
|
2397
3256
|
// `data-list-start` is the ONLY carrier of "marked's lexer opened a new
|
|
2398
3257
|
// list token here" (§3.8 rule (d)) and serializeBlocks() resets the
|
|
2399
3258
|
// ordinal counter on it. A copy is never a token boundary — it sits inside
|
|
2400
3259
|
// the run it was cloned from — so a clone that kept the attribute would
|
|
2401
3260
|
// restart the numbering: duplicating the first item of '1. alpha / 2.
|
|
2402
3261
|
// bravo' emits '1. alpha / 1. alpha / 2. bravo'.
|
|
2403
|
-
copy.removeAttribute('data-list-start');
|
|
2404
|
-
const span = run.slice(0, at + 1).concat(
|
|
3262
|
+
copies.forEach((copy) => copy.removeAttribute('data-list-start'));
|
|
3263
|
+
const span = run.slice(0, at + 1).concat(copies, run.slice(at + 1));
|
|
2405
3264
|
mutateListRun(() => {
|
|
2406
|
-
|
|
3265
|
+
// One fixed reference node, so the copies land in their own order:
|
|
3266
|
+
// insertBefore(c1, ref) then insertBefore(c2, ref) gives lastEl, c1, c2.
|
|
3267
|
+
const ref = lastEl.nextSibling;
|
|
3268
|
+
copies.forEach((copy) => { lastEl.parentNode.insertBefore(copy, ref); });
|
|
2407
3269
|
});
|
|
2408
3270
|
// No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
|
|
2409
3271
|
// DOM, the copy included. The map is what keeps both lines byte-identical
|
|
@@ -2422,6 +3284,15 @@
|
|
|
2422
3284
|
// list-md.js re-states each line's marker from that element's OWN
|
|
2423
3285
|
// attributes — which is the §3.8 renumbering, and which is also how the
|
|
2424
3286
|
// copy keeps its 型態 / 縮排 / 勾選狀態.
|
|
3287
|
+
// §3.3's collapse, declared immediately before the render inside
|
|
3288
|
+
// commitListStructure(). The copies land AFTER everything the set owns and
|
|
3289
|
+
// no member's line COUNT can change (a multi-line target is refused above,
|
|
3290
|
+
// and §3.8's renumbering only ever changes marker WIDTH), so the originals
|
|
3291
|
+
// still occupy exactly the range they occupied before the commit.
|
|
3292
|
+
declareCollapse({
|
|
3293
|
+
startLine: recs[0].startLine,
|
|
3294
|
+
endLine: recs[recs.length - 1].endLine,
|
|
3295
|
+
});
|
|
2425
3296
|
await commitListStructure(span, null, false,
|
|
2426
3297
|
{ presetRange: range, carryOver: bystanderCarryOver(span) });
|
|
2427
3298
|
}
|
|
@@ -2430,64 +3301,45 @@
|
|
|
2430
3301
|
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
2431
3302
|
// precondition as insertBlockBelow() above.
|
|
2432
3303
|
async function deleteBlockViaGutter(blockEl) {
|
|
2433
|
-
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
// pointing at a
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
// Measured before this line existed: edit a paragraph, press ⠿ without
|
|
2446
|
-
// blurring, 刪除 — the gesture was dropped with '文件已更新,請重試這個操作'
|
|
2447
|
-
// and the block stayed on screen. The narrowed re-resolve (startLine +
|
|
2448
|
-
// type, no fingerprint) is used ONLY when the session that just committed
|
|
2449
|
-
// was this block's OWN; reresolveBlockEl() keeps its fingerprint for
|
|
2450
|
-
// everybody else.
|
|
2451
|
-
const selfSession = ownsOpenSession(blockEl);
|
|
2452
|
-
const ok = await switchAwayFrom();
|
|
2453
|
-
if (!ok) return;
|
|
2454
|
-
let liveBlockEl = blockEl;
|
|
2455
|
-
if (!document.body.contains(blockEl)) {
|
|
2456
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2457
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2458
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2459
|
-
}
|
|
2460
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2461
|
-
const block = blocks.find((b) => b.id === liveBlockId);
|
|
2462
|
-
if (!block) return;
|
|
2463
|
-
// Task 4 fix round 1 (Critical): refuse a block that owns no source line.
|
|
2464
|
-
// Its range is INVERTED (endLine === startLine - 1 — see blockOwnsNoLine()),
|
|
2465
|
-
// and commitListBlockRemoval() -> commitRangeRemoval() does not guard
|
|
2466
|
-
// `endLine >= startLine`: with sl=5, el=4 the blank-line absorption reads
|
|
2467
|
-
// state.lines[sl - 2], which for an inverted range is a blank line
|
|
2468
|
-
// belonging to a DIFFERENT block, finds it blank, and deletes it. Nothing
|
|
2469
|
-
// visible happens — no error, no banner, the .ed-block count is unchanged
|
|
2470
|
-
// — but the file loses a separator (measured: '# Doc\n\n- a\n\n- - b\n'
|
|
2471
|
-
// -> '# Doc\n\n- a\n- - b\n').
|
|
2472
|
-
//
|
|
2473
|
-
// Same predicate canWysiwygForLi() already refuses on, so "cannot be
|
|
2474
|
-
// armed" and "cannot be deleted" stay one decision. The ⠿ itself is NOT
|
|
2475
|
-
// gated on it: Task 4 requires every block to have a handle, and hiding it
|
|
2476
|
-
// would trade that requirement for a delete-path bug. Re-checked against
|
|
2477
|
-
// the LIVE block, after switchAwayFrom()'s possible re-render renumbered
|
|
2478
|
-
// the ids.
|
|
2479
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
3304
|
+
// §3.3's membership rules plus the shared preamble (identity capture,
|
|
3305
|
+
// switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
|
|
3306
|
+
// no-source-line refusal) — see resolveGutterOperands(). The stake on the
|
|
3307
|
+
// re-resolve is highest here: an id shift used to make this delete a
|
|
3308
|
+
// DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
|
|
3309
|
+
// block that survived.
|
|
3310
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
3311
|
+
if (!operands) return;
|
|
3312
|
+
const els = operands.els;
|
|
3313
|
+
const recs = operands.recs;
|
|
3314
|
+
const shape = spanListKinds(els);
|
|
3315
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
2480
3316
|
// Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
|
|
2481
3317
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
2482
3318
|
// below corrupts a list three separate ways — see
|
|
2483
|
-
//
|
|
2484
|
-
if (
|
|
2485
|
-
await
|
|
3319
|
+
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
3320
|
+
if (shape.allLi) {
|
|
3321
|
+
await deleteListItemsViaGutter(els);
|
|
2486
3322
|
return;
|
|
2487
3323
|
}
|
|
2488
|
-
|
|
3324
|
+
// A contiguous span of non-list blocks is one line range: the separators
|
|
3325
|
+
// between its members are inside it by construction (members are adjacent
|
|
3326
|
+
// in `blocks`, so nothing else lives between them), and commitRangeRemoval()
|
|
3327
|
+
// absorbs exactly one adjacent blank on the outside — the same blank-line
|
|
3328
|
+
// contract commitListBlockRemoval() documents, which is literally this call
|
|
3329
|
+
// for a single block. The one-block case keeps going through that wrapper so
|
|
3330
|
+
// the shipped single-block path is byte-for-byte the S2 one.
|
|
3331
|
+
const first = recs[0];
|
|
3332
|
+
const last = recs[recs.length - 1];
|
|
3333
|
+
const result = els.length === 1
|
|
3334
|
+
? commitListBlockRemoval({ lines, blocks, stack }, first.id)
|
|
3335
|
+
: commitRangeRemoval({ lines, blocks, stack }, first.startLine, last.endLine);
|
|
2489
3336
|
const prevLines = lines;
|
|
2490
3337
|
lines = result.lines;
|
|
3338
|
+
// §3.3's collapse: a delete's result covers NO lines, so the set is cleared
|
|
3339
|
+
// rather than collapsed (Task 1 carry 5 — collapseTo() answers null for an
|
|
3340
|
+
// inverted range, and §4.4 says a range that no longer resolves clears).
|
|
3341
|
+
// Declared immediately before the render, never earlier (Task 5 carry 5).
|
|
3342
|
+
declareCollapse(null);
|
|
2491
3343
|
const okRender = await safeRerenderAll();
|
|
2492
3344
|
if (!okRender) {
|
|
2493
3345
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
@@ -2522,31 +3374,44 @@
|
|
|
2522
3374
|
// commit the re-serialized survivors over that range with every one of them
|
|
2523
3375
|
// carried over verbatim — nothing here rewrites any survivor's CONTENT, only
|
|
2524
3376
|
// its marker and its leading columns.
|
|
2525
|
-
async function
|
|
2526
|
-
|
|
2527
|
-
|
|
3377
|
+
async function deleteListItemsViaGutter(liEls) {
|
|
3378
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
3379
|
+
const run = batchRunOf(liEls);
|
|
3380
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2528
3381
|
// §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
|
|
2529
3382
|
// exactly what listRunOf() returns (the outermost run PLUS every
|
|
2530
3383
|
// descendant of its members), so the deeper runs this delete is about to
|
|
2531
3384
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
2532
3385
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
2533
|
-
// target refuses per §4.1.
|
|
2534
|
-
if (!listRunSupportsStructuralEdit(run,
|
|
3386
|
+
// target refuses per §4.1 — and in a batch every member is a target.
|
|
3387
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
2535
3388
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2536
3389
|
if (!range) return;
|
|
2537
|
-
|
|
2538
|
-
|
|
3390
|
+
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
3391
|
+
// first member's — see spanMinIndent().
|
|
3392
|
+
const oldIndent = spanMinIndent(liEls);
|
|
3393
|
+
const survivors = run.filter((el) => liEls.indexOf(el) === -1);
|
|
2539
3394
|
mutateListRun(() => {
|
|
2540
|
-
// Clamp FIRST, while
|
|
2541
|
-
// what tells the pure function that
|
|
2542
|
-
// anything, and rule 2's scope is measured from
|
|
2543
|
-
|
|
2544
|
-
|
|
3395
|
+
// Clamp FIRST, while every member is still in the span: `{ removed: true }`
|
|
3396
|
+
// is what tells the pure function that these blocks can no longer anchor
|
|
3397
|
+
// anything, and rule 2's scope is measured from the LAST of their
|
|
3398
|
+
// positions. ONE call for the whole set, not one per member: clampIndents()
|
|
3399
|
+
// takes an array of operated indices precisely so a batch computes one
|
|
3400
|
+
// segment delta per §3.4 rule 3 instead of N independent clamps, which is
|
|
3401
|
+
// what splits the user's siblings apart.
|
|
3402
|
+
applyIndentClamp(run, liEls, oldIndent, { removed: true });
|
|
3403
|
+
liEls.forEach((el) => removeListItem(el));
|
|
2545
3404
|
});
|
|
2546
3405
|
// No `mutatedEl`: the deleted block is not among the survivors, and every
|
|
2547
3406
|
// survivor's own bytes are exactly what the file already holds. The marker
|
|
2548
3407
|
// is re-stated by the serializer regardless of the carry-over, which is
|
|
2549
3408
|
// what renumbers the run (§3.8) and applies the clamped indent.
|
|
3409
|
+
//
|
|
3410
|
+
// §3.3's collapse: a delete's result covers no lines, so the set is cleared.
|
|
3411
|
+
// Declared immediately before commitListStructure(), whose only render on
|
|
3412
|
+
// this path is the one it makes after the commit — the `!range` bail above
|
|
3413
|
+
// it cannot fire, `presetRange` is non-null by construction here.
|
|
3414
|
+
declareCollapse(null);
|
|
2550
3415
|
await commitListStructure(survivors, null, false,
|
|
2551
3416
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
2552
3417
|
}
|
|
@@ -2604,7 +3469,7 @@
|
|
|
2604
3469
|
// BETWEEN two runs, to neither. So the commit range has to be widened
|
|
2605
3470
|
// past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
|
|
2606
3471
|
// two places where that happens (§3.4's 2026-08-30 erratum); the other is
|
|
2607
|
-
// §4.3 rule 1's edge blanks in
|
|
3472
|
+
// §4.3 rule 1's edge blanks in convertListItemsAway() above.
|
|
2608
3473
|
//
|
|
2609
3474
|
// 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
|
|
2610
3475
|
// into a healthy one freezes the healthy one too — and declining to merge
|
|
@@ -2684,18 +3549,18 @@
|
|
|
2684
3549
|
return { startLine: startLine, endLine: endLine, ok: true };
|
|
2685
3550
|
}
|
|
2686
3551
|
|
|
2687
|
-
// The {listType, indent} a span member will carry once
|
|
2688
|
-
// `attrs`. Everything
|
|
2689
|
-
function postConvertLiAttrs(el,
|
|
3552
|
+
// The {listType, indent} a span member will carry once the operand set
|
|
3553
|
+
// `liEls` has become `attrs`. Everything outside the set keeps what it has.
|
|
3554
|
+
function postConvertLiAttrs(el, liEls, attrs) {
|
|
2690
3555
|
return {
|
|
2691
|
-
listType: el
|
|
3556
|
+
listType: liEls.indexOf(el) !== -1
|
|
2692
3557
|
? attrs.listType
|
|
2693
3558
|
: (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
|
|
2694
3559
|
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
2695
3560
|
};
|
|
2696
3561
|
}
|
|
2697
3562
|
|
|
2698
|
-
async function
|
|
3563
|
+
async function convertListItemsType(liEls, run, recs, target) {
|
|
2699
3564
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2700
3565
|
if (!range) return;
|
|
2701
3566
|
const attrs = convertMd.listAttrsFor(target);
|
|
@@ -2719,22 +3584,30 @@
|
|
|
2719
3584
|
if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
|
|
2720
3585
|
});
|
|
2721
3586
|
const merged = widenRangeForListMerge(range, run,
|
|
2722
|
-
postConvertLiAttrs(headEl,
|
|
3587
|
+
postConvertLiAttrs(headEl, liEls, attrs), postConvertLiAttrs(tailEl, liEls, attrs));
|
|
2723
3588
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
3589
|
+
// How far the run's own first line MOVES. widenRangeForListMerge() only ever
|
|
3590
|
+
// widens the range BACKWARDS over blank separators (§4.3 rule 2's tight
|
|
3591
|
+
// merge), and the markdown written over the widened range is the run's own
|
|
3592
|
+
// serialization with no leading blank — so every line of the run shifts up
|
|
3593
|
+
// by exactly this much, and the collapse range below has to shift with it.
|
|
3594
|
+
const shift = merged.startLine - range.startLine;
|
|
2724
3595
|
range.startLine = merged.startLine;
|
|
2725
3596
|
range.endLine = merged.endLine;
|
|
2726
3597
|
mutateListRun(() => {
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
box
|
|
2737
|
-
|
|
3598
|
+
liEls.forEach((liEl) => {
|
|
3599
|
+
liEl.setAttribute('data-list-type', attrs.listType);
|
|
3600
|
+
liEl.setAttribute('data-task', attrs.task ? '1' : '0');
|
|
3601
|
+
const box = liCheckEl(liEl);
|
|
3602
|
+
if (attrs.task) {
|
|
3603
|
+
// Insert BEFORE the surface: §4.1 fixes the child order as
|
|
3604
|
+
// marker → check → text, and list-md.js's firstChildWithClass() plus
|
|
3605
|
+
// the delegated checkbox-toggle listener both assume it.
|
|
3606
|
+
if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
|
|
3607
|
+
} else if (box) {
|
|
3608
|
+
box.remove();
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
2738
3611
|
});
|
|
2739
3612
|
// NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
|
|
2740
3613
|
// sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
|
|
@@ -2750,6 +3623,15 @@
|
|
|
2750
3623
|
// a carried line, i.e. it re-states the marker from the NEW attributes,
|
|
2751
3624
|
// and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
|
|
2752
3625
|
// carried source. That is what makes '- [x] alpha' → '- alpha' work.
|
|
3626
|
+
//
|
|
3627
|
+
// §3.3's collapse: the operand set stays exactly where it is — the members
|
|
3628
|
+
// are still list items in the same run and only their MARKERS are re-stated,
|
|
3629
|
+
// so no line count inside the run can change. Only the merge widening moves
|
|
3630
|
+
// them, by `shift`.
|
|
3631
|
+
declareCollapse({
|
|
3632
|
+
startLine: recs[0].startLine + shift,
|
|
3633
|
+
endLine: recs[recs.length - 1].endLine + shift,
|
|
3634
|
+
});
|
|
2753
3635
|
await commitListStructure(run, null, false,
|
|
2754
3636
|
{ presetRange: range, carryOver: bystanderCarryOver(run) });
|
|
2755
3637
|
}
|
|
@@ -2788,17 +3670,39 @@
|
|
|
2788
3670
|
// run-wide veto and re-checked against the TARGET's line range only).
|
|
2789
3671
|
// Measured; the 'a multi-line bystander is replayed, not refused' scenario
|
|
2790
3672
|
// is what notices.
|
|
2791
|
-
async function
|
|
3673
|
+
async function convertListItemsAway(liEls, run, recs, target) {
|
|
2792
3674
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2793
3675
|
if (!range) return;
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
3676
|
+
// Each member's own bytes, read from `lines` and never re-serialized — that
|
|
3677
|
+
// is what keeps a `~5px` a `~5px`. Joined by a blank line so N converted
|
|
3678
|
+
// items re-lex as N blocks and not one lazy continuation of the first.
|
|
3679
|
+
const convertedPieces = [];
|
|
3680
|
+
for (let i = 0; i < liEls.length; i++) {
|
|
3681
|
+
const rec = recs[i];
|
|
3682
|
+
const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
|
|
3683
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
3684
|
+
convertedPieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
3685
|
+
}
|
|
3686
|
+
const convertedMd = convertedPieces.join('\n\n');
|
|
3687
|
+
|
|
3688
|
+
// §3.4 rule 3's batch anchor — the SMALLEST old indent in the set.
|
|
3689
|
+
const oldIndent = spanMinIndent(liEls);
|
|
3690
|
+
// ONE contiguous index range, never a loop over `run.indexOf(member)`
|
|
3691
|
+
// per item: the run is split ONCE into the survivors before the set and the
|
|
3692
|
+
// survivors after it. The operand set is contiguous in `blocks` (checked by
|
|
3693
|
+
// spanIsContiguous() before any of this) and the run is a contiguous slice
|
|
3694
|
+
// of the same list, so the two indices below bracket exactly `liEls.length`
|
|
3695
|
+
// members — asserted rather than assumed, because this arithmetic is what
|
|
3696
|
+
// writes the bytes and an off-by-one here silently re-serializes a
|
|
3697
|
+
// bystander into the converted half.
|
|
3698
|
+
const idx = run.indexOf(liEls[0]);
|
|
3699
|
+
const lastIdx = run.indexOf(liEls[liEls.length - 1]);
|
|
3700
|
+
if (idx < 0 || lastIdx < idx || lastIdx - idx + 1 !== liEls.length) {
|
|
3701
|
+
refuseStructuralListEdit(BATCH_GAP_MESSAGE);
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
2800
3704
|
const before = run.slice(0, idx);
|
|
2801
|
-
const after = run.slice(
|
|
3705
|
+
const after = run.slice(lastIdx + 1);
|
|
2802
3706
|
|
|
2803
3707
|
// §3.4, and the FIRST production caller of the pure clamp's
|
|
2804
3708
|
// `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
|
|
@@ -2824,17 +3728,21 @@
|
|
|
2824
3728
|
// The 'the §3.4 segment deltas survive the split commit' scenario is that
|
|
2825
3729
|
// shape, and it is the one that goes red without this option.
|
|
2826
3730
|
mutateListRun(() => {
|
|
2827
|
-
applyIndentClamp(run,
|
|
3731
|
+
applyIndentClamp(run, liEls, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
|
|
2828
3732
|
});
|
|
2829
3733
|
|
|
2830
3734
|
// No `mutatedEl`: the converted block is in neither half, and every
|
|
2831
3735
|
// survivor's bytes are exactly what the file already holds. Naming a block
|
|
2832
3736
|
// here EXCLUDES it from the replay map, which is what sends its content
|
|
2833
|
-
// back through escapeText() — see
|
|
3737
|
+
// back through escapeText() — see convertListItemsType()'s note.
|
|
2834
3738
|
const carry = bystanderCarryOver(before.concat(after));
|
|
2835
3739
|
const pieces = [];
|
|
2836
3740
|
if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
|
|
2837
|
-
|
|
3741
|
+
// Which piece the converted blocks are — the collapse range below counts
|
|
3742
|
+
// emitted LINES up to it, and "0 or 1" is only true while `before` is the
|
|
3743
|
+
// one optional piece ahead of it.
|
|
3744
|
+
const convertedPieceIdx = pieces.length;
|
|
3745
|
+
pieces.push(convertedMd);
|
|
2838
3746
|
if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
|
|
2839
3747
|
let md = pieces.join('\n\n');
|
|
2840
3748
|
|
|
@@ -2845,15 +3753,27 @@
|
|
|
2845
3753
|
// 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
|
|
2846
3754
|
// added only when the neighbour is not already blank, which is also what
|
|
2847
3755
|
// 「正規化連續空行」 amounts to here: no double separator is ever created.
|
|
3756
|
+
let lead = 0;
|
|
2848
3757
|
if (!before.length && range.startLine > 1 &&
|
|
2849
|
-
String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
|
|
3758
|
+
String(lines[range.startLine - 2]).trim() !== '') { md = '\n' + md; lead = 1; }
|
|
2850
3759
|
if (!after.length && range.endLine < lines.length &&
|
|
2851
3760
|
String(lines[range.endLine]).trim() !== '') md = md + '\n';
|
|
2852
3761
|
|
|
3762
|
+
// §3.3's collapse: 「操作後集合塌縮為操作結果所涵蓋的行區間」— the CONVERTED
|
|
3763
|
+
// blocks' own lines, not the whole commit range (which is the entire run,
|
|
3764
|
+
// bystanders included). Counted in emitted lines: every '\n\n' join
|
|
3765
|
+
// between two pieces adds one blank line on top of the piece's own lines,
|
|
3766
|
+
// and `lead` is the one §4.3 rule 1 puts at the very front.
|
|
3767
|
+
let offset = lead;
|
|
3768
|
+
for (let k = 0; k < convertedPieceIdx; k++) offset += pieces[k].split('\n').length + 1;
|
|
3769
|
+
const outStart = range.startLine + offset;
|
|
3770
|
+
const outEnd = outStart + convertedMd.split('\n').length - 1;
|
|
3771
|
+
|
|
2853
3772
|
const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
|
|
2854
3773
|
if (result.op === null) return;
|
|
2855
3774
|
const prevLines = lines;
|
|
2856
3775
|
lines = result.lines;
|
|
3776
|
+
declareCollapse({ startLine: outStart, endLine: outEnd });
|
|
2857
3777
|
if (!(await safeRerenderAll())) {
|
|
2858
3778
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2859
3779
|
}
|
|
@@ -2873,26 +3793,44 @@
|
|
|
2873
3793
|
// to no run. The gate that DOES apply is the one inside
|
|
2874
3794
|
// widenRangeForListMerge(), on whichever neighbouring run this block is
|
|
2875
3795
|
// about to merge into.
|
|
2876
|
-
async function
|
|
3796
|
+
async function convertBlocksIntoList(blockEls, recs, kinds, target) {
|
|
2877
3797
|
const attrs = convertMd.listAttrsFor(target);
|
|
2878
3798
|
if (!attrs) return;
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
3799
|
+
// One item per member, joined by a bare newline: the members' own
|
|
3800
|
+
// separators are INSIDE the commit range and are replaced, which is what
|
|
3801
|
+
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
3802
|
+
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
3803
|
+
const pieces = [];
|
|
3804
|
+
for (let i = 0; i < recs.length; i++) {
|
|
3805
|
+
const stripped = convertMd.stripMarker(
|
|
3806
|
+
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
3807
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
3808
|
+
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
3809
|
+
}
|
|
3810
|
+
const md = pieces.join('\n');
|
|
3811
|
+
const first = recs[0];
|
|
3812
|
+
const last = recs[recs.length - 1];
|
|
2882
3813
|
|
|
2883
3814
|
// emitAs() puts a list target at column 0 with no indent prefix, so the
|
|
2884
|
-
//
|
|
2885
|
-
// edges — it emits one item, however many physical lines
|
|
3815
|
+
// span's post-conversion identity is (target list type, indent 0) on both
|
|
3816
|
+
// edges — it emits one item per member, however many physical lines each
|
|
3817
|
+
// item spans.
|
|
2886
3818
|
const self = { listType: attrs.listType, indent: 0 };
|
|
2887
3819
|
const merged = widenRangeForListMerge(
|
|
2888
|
-
{ startLine:
|
|
3820
|
+
{ startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
|
|
2889
3821
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
2890
3822
|
|
|
2891
3823
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2892
|
-
merged.startLine, merged.endLine,
|
|
3824
|
+
merged.startLine, merged.endLine, md);
|
|
2893
3825
|
if (result.op === null) return;
|
|
2894
3826
|
const prevLines = lines;
|
|
2895
3827
|
lines = result.lines;
|
|
3828
|
+
// §3.3's collapse: the emitted items occupy the whole (possibly widened)
|
|
3829
|
+
// commit range — nothing else is written here.
|
|
3830
|
+
declareCollapse({
|
|
3831
|
+
startLine: merged.startLine,
|
|
3832
|
+
endLine: merged.startLine + md.split('\n').length - 1,
|
|
3833
|
+
});
|
|
2896
3834
|
if (!(await safeRerenderAll())) {
|
|
2897
3835
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2898
3836
|
}
|
|
@@ -3062,6 +4000,10 @@
|
|
|
3062
4000
|
hideTableGrips();
|
|
3063
4001
|
hideTableEdgeMenu();
|
|
3064
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();
|
|
3065
4007
|
if (!burst) return true;
|
|
3066
4008
|
// §10-gap fix (review): this burst's block's "pristine" window (see
|
|
3067
4009
|
// `pristineInsert`'s own comment) closes right here, the moment its
|
|
@@ -3308,6 +4250,408 @@
|
|
|
3308
4250
|
return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
3309
4251
|
}
|
|
3310
4252
|
|
|
4253
|
+
// ── S3 Task 2: block multi-select state ────────────────────────────────
|
|
4254
|
+
// The selection's identity is a LINE RANGE, never ids and never nodes.
|
|
4255
|
+
// buildBlockMap renumbers every id from 0 on every render (blockmap.js's
|
|
4256
|
+
// `nextId = {v:0}`) and every batch operation triggers a full
|
|
4257
|
+
// rerenderAll(), so an id or an element held across a commit is a dangling
|
|
4258
|
+
// reference into a document that no longer exists — the same reasoning
|
|
4259
|
+
// blockElAtLine() above is written down for. All the range arithmetic and
|
|
4260
|
+
// §3.3's membership rules live in the pure, node-tested
|
|
4261
|
+
// lib/editor/selection.js (window.md2docSelection); this file only paints.
|
|
4262
|
+
let blockSelection = null; // { anchorLine, focusLine } | null
|
|
4263
|
+
|
|
4264
|
+
// §4.4: the focus endpoint's block element. Null when the selection's focus
|
|
4265
|
+
// line is not (or is no longer) some block's startLine — the caller then
|
|
4266
|
+
// simply holds no focus rather than guessing at a neighbour.
|
|
4267
|
+
function selectionFocusBlockEl() {
|
|
4268
|
+
if (!blockSelection) return null;
|
|
4269
|
+
return blockElAtLine(blockSelection.focusLine);
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
// Repaints `.ed-selected` and the roving tabindex from `blockSelection`.
|
|
4273
|
+
// Idempotent and total: it clears both attributes off EVERY block first, so
|
|
4274
|
+
// it is equally the "apply" and the "clear" path and no stale tint can
|
|
4275
|
+
// survive a state change.
|
|
4276
|
+
function applySelectionClasses() {
|
|
4277
|
+
const members = blockSelection && selectionLib
|
|
4278
|
+
? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
4279
|
+
const ids = new Set(members.map((b) => String(b.id)));
|
|
4280
|
+
for (const el of allBlockEls()) {
|
|
4281
|
+
el.classList.toggle('ed-selected', ids.has(el.getAttribute('data-block-id')));
|
|
4282
|
+
el.removeAttribute('tabindex');
|
|
4283
|
+
}
|
|
4284
|
+
// §4.4 wants a REAL focus holder: with focus left on <body> the keydown
|
|
4285
|
+
// dispatch has nothing to anchor on and the browser's own Tab order walks
|
|
4286
|
+
// straight past the selection. A ROVING tabindex="-1" (exactly one block
|
|
4287
|
+
// focusable at a time, moved as the focus endpoint moves) is the standard
|
|
4288
|
+
// answer, and -1 rather than 0 keeps every block out of the sequential Tab
|
|
4289
|
+
// order — Tab inside the editor is already a structural key. There is no
|
|
4290
|
+
// other tabindex anywhere in lib/; this is greenfield.
|
|
4291
|
+
//
|
|
4292
|
+
// Focusing a .ed-block is inert for the burst machinery: the delegated
|
|
4293
|
+
// focusin handler bails unless the target closes onto .ed-wys-cell or
|
|
4294
|
+
// .ed-wys-armed, and the block WRAPPER is neither (armEditables() arms
|
|
4295
|
+
// blockContentEl(), a child).
|
|
4296
|
+
const focusEl = selectionFocusBlockEl();
|
|
4297
|
+
if (focusEl) { focusEl.setAttribute('tabindex', '-1'); focusEl.focus(); }
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
// S3 Task 4: the keyboard's anchor for §4.4 entry (c) once the set is gone.
|
|
4301
|
+
// MEASURED on 2026-08-30, contradicting Task 2 carry 7 / Task 3 carry 7:
|
|
4302
|
+
// removing the roving `tabindex` from the block that currently has DOM
|
|
4303
|
+
// focus BLURS it in Chromium (`document.activeElement` becomes <body>), so
|
|
4304
|
+
// applySelectionClasses()'s "clear both attributes off EVERY block first"
|
|
4305
|
+
// means a cleared selection leaves NO focused block behind. Without this
|
|
4306
|
+
// memory there is nothing for Shift+↑↓ to resume from after an Escape: the
|
|
4307
|
+
// caret's own surface owns those keys (they are the browser's
|
|
4308
|
+
// extend-the-text-selection gesture, and the burst short-circuit keeps them
|
|
4309
|
+
// that way), and <body> has no block to anchor on.
|
|
4310
|
+
let lastSelectionFocusLine = null;
|
|
4311
|
+
|
|
4312
|
+
function setBlockSelection(sel) {
|
|
4313
|
+
if (sel && Number.isFinite(Number(sel.focusLine))) lastSelectionFocusLine = Number(sel.focusLine);
|
|
4314
|
+
blockSelection = sel;
|
|
4315
|
+
applySelectionClasses();
|
|
4316
|
+
}
|
|
4317
|
+
function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
|
|
4318
|
+
|
|
4319
|
+
// ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
|
|
4320
|
+
// `contentEl.innerHTML` is replaced wholesale on every commit, so the
|
|
4321
|
+
// painted tint and the roving focus holder are destroyed by definition —
|
|
4322
|
+
// MEASURED on this branch: with a selection over lines 3–4 standing, a
|
|
4323
|
+
// rerenderAll() leaves `blockSelection` intact (its identity is a line
|
|
4324
|
+
// range, which no render can invalidate) but the fresh server HTML carries
|
|
4325
|
+
// no `.ed-selected` and no `tabindex`, so `document.activeElement` is
|
|
4326
|
+
// `<body>` and the keyboard is dead while the model still says two blocks
|
|
4327
|
+
// are selected. The model and the paint drift apart, and every batch
|
|
4328
|
+
// operation in Tasks 6/7 ends in exactly this swap.
|
|
4329
|
+
//
|
|
4330
|
+
// NOTE for the plan's own wording: it says "without the rebuild the tint
|
|
4331
|
+
// survives (it is re-derived from lines) but focus falls back to <body>".
|
|
4332
|
+
// Only the second half is true. Nothing re-derives the tint — the swap
|
|
4333
|
+
// simply throws the classes away with the nodes that carried them.
|
|
4334
|
+
//
|
|
4335
|
+
// §4.4 also says each structural operation DECLARES the line range it
|
|
4336
|
+
// produced, and that a range which no longer resolves clears the selection
|
|
4337
|
+
// rather than leaving it dangling. `pendingSelectionRange` is that
|
|
4338
|
+
// declaration: `undefined` means "nothing declared, keep whatever is
|
|
4339
|
+
// standing" (a burst commit must not destroy a selection it never touched),
|
|
4340
|
+
// `null` means "clear" (undo/redo), and a `{startLine, endLine}` means
|
|
4341
|
+
// "collapse to this" (Tasks 6/7's batch operations).
|
|
4342
|
+
let pendingSelectionRange;
|
|
4343
|
+
function declareSelectionRange(range) { pendingSelectionRange = range; }
|
|
4344
|
+
|
|
4345
|
+
// §4.4 step 3: the rebuilt set must have a REAL focus holder, not <body>.
|
|
4346
|
+
// A declared range's focus endpoint is a LINE, and after a render that line
|
|
4347
|
+
// is often the INSIDE of a block rather than its startLine — collapseTo()
|
|
4348
|
+
// hands back the range's endLine, and a table or a fence owns four lines
|
|
4349
|
+
// for one block. selectionFocusBlockEl() answers null for such a line
|
|
4350
|
+
// (Task 2 carry 5), i.e. a selection with no focus holder and a dead
|
|
4351
|
+
// keyboard, which is the whole failure this task exists to prevent. So the
|
|
4352
|
+
// focus endpoint is snapped onto the startLine of the member it lands in,
|
|
4353
|
+
// on the side it was already on so the gesture stays reversible.
|
|
4354
|
+
//
|
|
4355
|
+
// The snap is REFUSED if it would change the member set: Tasks 6/7 compute
|
|
4356
|
+
// the batch anchor from these members and that anchor writes bytes, so a
|
|
4357
|
+
// focus holder is not worth a silently different set. That branch is
|
|
4358
|
+
// defensive — snapping only ever shrinks the range towards the anchor, and
|
|
4359
|
+
// a member that would drop out would have to start after the block the
|
|
4360
|
+
// focus line lands in — but "buildBlockMap never emits that" is an argument
|
|
4361
|
+
// about another file's output, not an invariant this one can enforce.
|
|
4362
|
+
function selectionWithFocusHolder(sel, members) {
|
|
4363
|
+
if (blockElAtLine(sel.focusLine)) return sel;
|
|
4364
|
+
const snapTo = Number(sel.focusLine) < Number(sel.anchorLine)
|
|
4365
|
+
? members[0] : members[members.length - 1];
|
|
4366
|
+
if (!snapTo) return sel;
|
|
4367
|
+
const candidate = { anchorLine: sel.anchorLine, focusLine: snapTo.startLine };
|
|
4368
|
+
const after = selectionLib.membersOf(candidate, blocks);
|
|
4369
|
+
if (after.length !== members.length) return sel;
|
|
4370
|
+
for (let i = 0; i < after.length; i++) if (after[i] !== members[i]) return sel;
|
|
4371
|
+
return blockElAtLine(candidate.focusLine) ? candidate : sel;
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// §4.4 step 2, called from rerenderAll() AFTER its unconditional teardown
|
|
4375
|
+
// (so nothing below can null what this just set) and BEFORE the two
|
|
4376
|
+
// `try`-swallowed rebind blocks (so a diagram-init throw cannot skip it).
|
|
4377
|
+
// `armEditables()` still runs first, per its own comment.
|
|
4378
|
+
function rebuildBlockSelection(declaredRange) {
|
|
4379
|
+
// Nothing standing and nothing declared: the fresh DOM already carries no
|
|
4380
|
+
// tint and no tabindex, so there is nothing to repaint and no reason to
|
|
4381
|
+
// walk every block on a render that has no selection anywhere near it.
|
|
4382
|
+
if (declaredRange === undefined && !blockSelection) return;
|
|
4383
|
+
const sel = declaredRange === undefined
|
|
4384
|
+
? blockSelection
|
|
4385
|
+
: (selectionLib ? selectionLib.collapseTo(declaredRange) : null);
|
|
4386
|
+
if (!sel || !selectionLib) { clearBlockSelection(); return; }
|
|
4387
|
+
const members = selectionLib.membersOf(sel, blocks);
|
|
4388
|
+
if (!members.length) { clearBlockSelection(); return; }
|
|
4389
|
+
setBlockSelection(selectionWithFocusHolder(sel, members));
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
// The block record behind a rendered `.ed-block`, or null. Every gesture
|
|
4393
|
+
// below turns an element (or a point) into a LINE this way — the line is
|
|
4394
|
+
// the selection's identity, the id is only how the DOM addresses it in
|
|
4395
|
+
// between two renders.
|
|
4396
|
+
function blockRecOf(blockEl) {
|
|
4397
|
+
if (!blockEl || !blockEl.getAttribute) return null;
|
|
4398
|
+
const raw = blockEl.getAttribute('data-block-id');
|
|
4399
|
+
if (raw === null) return null; // provisional block: no record yet
|
|
4400
|
+
return blocks.find((b) => b.id === Number(raw)) || null;
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
// Test-only hooks. Task 2 shipped the two WRITE hooks (no gesture created a
|
|
4404
|
+
// selection yet); Task 4 adds the READ one so a gesture scenario can assert
|
|
4405
|
+
// the resulting member set BY LINE RANGE rather than by counting tinted
|
|
4406
|
+
// nodes — a test that counts `.ed-selected` silently passes or fails on
|
|
4407
|
+
// Task 2's CSS instead of on the gesture under test. `memberLines` is the
|
|
4408
|
+
// model's own answer (selection.js against `blocks`); `domSelectedLines` is
|
|
4409
|
+
// the same question asked of the DOM, so a scenario can pin both and catch
|
|
4410
|
+
// the two drifting apart. They are the browser-side counterpart of the
|
|
4411
|
+
// node-side `module.exports` guard at the top of this file — the same
|
|
4412
|
+
// "expose the seam the tests need, in the one environment that has it"
|
|
4413
|
+
// split — and nothing in the product calls them.
|
|
4414
|
+
window.__edTestSetSelection = function (anchorLine, focusLine) {
|
|
4415
|
+
setBlockSelection({ anchorLine: anchorLine, focusLine: focusLine });
|
|
4416
|
+
};
|
|
4417
|
+
window.__edTestClearSelection = function () { clearBlockSelection(); };
|
|
4418
|
+
window.__edTestGetSelection = function () {
|
|
4419
|
+
if (!blockSelection) return null;
|
|
4420
|
+
const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
4421
|
+
return {
|
|
4422
|
+
anchorLine: blockSelection.anchorLine,
|
|
4423
|
+
focusLine: blockSelection.focusLine,
|
|
4424
|
+
memberLines: members.map((b) => [b.startLine, b.endLine]),
|
|
4425
|
+
domSelectedLines: allBlockEls()
|
|
4426
|
+
.filter((el) => el.classList.contains('ed-selected'))
|
|
4427
|
+
.map((el) => { const r = blockRecOf(el); return r ? [r.startLine, r.endLine] : null; }),
|
|
4428
|
+
focusHolderId: (function () {
|
|
4429
|
+
const el = selectionFocusBlockEl();
|
|
4430
|
+
return el ? el.getAttribute('data-block-id') : null;
|
|
4431
|
+
})(),
|
|
4432
|
+
};
|
|
4433
|
+
};
|
|
4434
|
+
// Task 5 seams. `__edTestForceRerender` runs the REAL rerenderAll() (not
|
|
4435
|
+
// safeRerenderAll(), so a scenario sees a throw rather than a banner) —
|
|
4436
|
+
// every batch operation in Tasks 6/7 ends in one, and this is how a
|
|
4437
|
+
// scenario exercises the swap without also exercising a batch operation
|
|
4438
|
+
// that does not exist yet. `__edTestTruncateTo` drops `lines` to its first
|
|
4439
|
+
// n, which is how a scenario reaches the "the selection's lines no longer
|
|
4440
|
+
// parse" state without an operation that deletes blocks.
|
|
4441
|
+
// Task 6 seam: the raw `blocks` records. A scenario needs them to prove the
|
|
4442
|
+
// shape of its OWN fixture — specifically that a no-line PHANTOM really does
|
|
4443
|
+
// sit BETWEEN two members, which is an INVERTED {startLine, endLine} at a
|
|
4444
|
+
// known index. Nothing in the DOM carries that fact: the phantom's element is
|
|
4445
|
+
// there, its (missing) line range is not, so a gap scenario that asserted the
|
|
4446
|
+
// gap from the DOM alone would be asserting something it merely believes.
|
|
4447
|
+
window.__edTestBlocks = function () {
|
|
4448
|
+
return blocks.map((b) => ({
|
|
4449
|
+
id: b.id, type: b.type, startLine: b.startLine, endLine: b.endLine,
|
|
4450
|
+
}));
|
|
4451
|
+
};
|
|
4452
|
+
window.__edTestForceRerender = function () { return rerenderAll(); };
|
|
4453
|
+
window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
|
|
4454
|
+
|
|
4455
|
+
// ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
|
|
4456
|
+
// §4.4 entries: (a) press inside a block and drag across its boundary;
|
|
4457
|
+
// (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
|
|
4458
|
+
// and a click inside any block without Shift. Scrolling and window blur
|
|
4459
|
+
// deliberately do NOT clear — the drag they abort is torn down, the
|
|
4460
|
+
// selection it built stands.
|
|
4461
|
+
//
|
|
4462
|
+
// Everything below turns a POINT or an ELEMENT into a LINE and hands it to
|
|
4463
|
+
// selection.js; no gesture ever holds an id or a node across a repaint.
|
|
4464
|
+
|
|
4465
|
+
// Chrome that owns its own press/click semantics. A gesture must never be
|
|
4466
|
+
// armed on one of these, or Shift+Clicking the ⠿ handle would build a
|
|
4467
|
+
// selection instead of opening the menu, and a drag inside the raw
|
|
4468
|
+
// textarea would fight its own text selection.
|
|
4469
|
+
const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-insert-menu, ' +
|
|
4470
|
+
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-conflict, .ed-raw';
|
|
4471
|
+
function isSelGestureChrome(target) {
|
|
4472
|
+
return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
// The line a gesture landing on `target` selects, or null when there is
|
|
4476
|
+
// none. Task 1 carry 3 / Task 2 carry 5: the focus endpoint must always be
|
|
4477
|
+
// the `startLine` of a block that OWNS a line — selectionFocusBlockEl()
|
|
4478
|
+
// returns null for anything else, which would leave a selection with no
|
|
4479
|
+
// focus holder and a dead keyboard. A phantom (the outer item of a
|
|
4480
|
+
// same-line nest, `endLine < startLine`) is therefore never the answer; its
|
|
4481
|
+
// own first line-owning descendant is, since that descendant is what the
|
|
4482
|
+
// user sees inside the phantom's box.
|
|
4483
|
+
function selectableLineOf(target) {
|
|
4484
|
+
let el = target && target.closest ? target.closest('.ed-block') : null;
|
|
4485
|
+
while (el) {
|
|
4486
|
+
const rec = blockRecOf(el);
|
|
4487
|
+
if (rec && rec.endLine >= rec.startLine) return rec.startLine;
|
|
4488
|
+
const inner = el.querySelector('.ed-block');
|
|
4489
|
+
const innerRec = inner ? blockRecOf(inner) : null;
|
|
4490
|
+
if (innerRec && innerRec.endLine >= innerRec.startLine) return innerRec.startLine;
|
|
4491
|
+
el = el.parentElement && el.parentElement.closest ? el.parentElement.closest('.ed-block') : null;
|
|
4492
|
+
}
|
|
4493
|
+
return null;
|
|
4494
|
+
}
|
|
4495
|
+
|
|
4496
|
+
function selectableLineAtPoint(clientX, clientY) {
|
|
4497
|
+
// Coordinates, not `e.target`: once a drag is under pointer capture every
|
|
4498
|
+
// pointer event retargets to the capture element, so the target says
|
|
4499
|
+
// nothing about what is under the cursor. elementFromPoint always does.
|
|
4500
|
+
const el = document.elementFromPoint(clientX, clientY);
|
|
4501
|
+
if (!el || !contentEl.contains(el)) return null;
|
|
4502
|
+
return selectableLineOf(el);
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
function focusedSelectableLine() {
|
|
4506
|
+
const el = document.activeElement;
|
|
4507
|
+
return el && el.closest ? selectableLineOf(el) : null;
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
function clearNativeTextSelection() {
|
|
4511
|
+
const s = window.getSelection();
|
|
4512
|
+
if (s && typeof s.removeAllRanges === 'function') s.removeAllRanges();
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
// The in-flight press. `dragging` flips only once the pointer has crossed
|
|
4516
|
+
// into a DIFFERENT block — §4.4's entry threshold is that boundary, not a
|
|
4517
|
+
// pixel distance, so a press-and-wiggle inside one block stays ordinary
|
|
4518
|
+
// text selection.
|
|
4519
|
+
let blockSelDrag = null;
|
|
4520
|
+
// A gesture's own trailing `click`. A press in one block released in
|
|
4521
|
+
// another still fires one (on their common ancestor), and
|
|
4522
|
+
// wireBlockSelection()'s click handler would answer it with either
|
|
4523
|
+
// switchAwayFrom() (released outside any block) or the §4.4 exit rule
|
|
4524
|
+
// (released inside one) — the second of which would clear the very
|
|
4525
|
+
// selection the drag just built. Consumed exactly once, and re-armed to
|
|
4526
|
+
// false by the next pointerdown so a gesture whose click never arrives
|
|
4527
|
+
// (an abort, a release outside the window) cannot swallow a later one.
|
|
4528
|
+
let blockSelClickSuppressed = false;
|
|
4529
|
+
|
|
4530
|
+
function armBlockSelDrag(e) {
|
|
4531
|
+
if (isSelGestureChrome(e.target)) return;
|
|
4532
|
+
const line = selectableLineOf(e.target);
|
|
4533
|
+
if (line === null) return;
|
|
4534
|
+
blockSelDrag = {
|
|
4535
|
+
pointerId: e.pointerId,
|
|
4536
|
+
originLine: line,
|
|
4537
|
+
// §4.4's table exception: a drag whose origin is a cell and which never
|
|
4538
|
+
// leaves that table keeps native text selection. Measured: a table is
|
|
4539
|
+
// exactly ONE .ed-block, so the block-boundary rule already says the
|
|
4540
|
+
// same thing — this is kept explicit because it is the spec's own
|
|
4541
|
+
// wording and because it stays correct if a cell ever comes to contain
|
|
4542
|
+
// blocks of its own.
|
|
4543
|
+
originTableEl: e.target.closest ? e.target.closest('table') : null,
|
|
4544
|
+
focusLine: line,
|
|
4545
|
+
dragging: false,
|
|
4546
|
+
captureEl: null,
|
|
4547
|
+
};
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
// Mirrors the table drag's capture skeleton (setPointerCapture at the top
|
|
4551
|
+
// of the te pointerdown, releaseTeCapture() on every exit): capture is what
|
|
4552
|
+
// keeps pointermove/up arriving once the cursor leaves the window. Taken at
|
|
4553
|
+
// ENGAGE time rather than at press time, so a press that turns out to be
|
|
4554
|
+
// plain text selection is never interfered with, and on documentElement
|
|
4555
|
+
// rather than the pressed node, which a mid-gesture commit can detach.
|
|
4556
|
+
function captureBlockSelDrag(e) {
|
|
4557
|
+
const el = document.documentElement;
|
|
4558
|
+
if (el && typeof el.setPointerCapture === 'function') {
|
|
4559
|
+
try { el.setPointerCapture(e.pointerId); blockSelDrag.captureEl = el; }
|
|
4560
|
+
catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
// Unconditional teardown of the in-flight press, called from pointerup,
|
|
4565
|
+
// pointercancel, the window blur listener, the next pointerdown, and the
|
|
4566
|
+
// "no buttons are down any more" guard in pointermove. The SELECTION is
|
|
4567
|
+
// never touched here: §4.4 says blur does not clear, and an aborted drag
|
|
4568
|
+
// must leave a complete set rather than a half-built one.
|
|
4569
|
+
function endBlockSelDrag() {
|
|
4570
|
+
if (!blockSelDrag) return;
|
|
4571
|
+
const st = blockSelDrag;
|
|
4572
|
+
blockSelDrag = null;
|
|
4573
|
+
if (st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
|
|
4574
|
+
try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
|
|
4575
|
+
}
|
|
4576
|
+
if (st.dragging) blockSelClickSuppressed = true;
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
function updateBlockSelDrag(e) {
|
|
4580
|
+
if (!blockSelDrag || e.pointerId !== blockSelDrag.pointerId) return;
|
|
4581
|
+
if (tePointer) return; // a grip gesture owns this pointer
|
|
4582
|
+
// The browser can simply never deliver a pointerup (released over browser
|
|
4583
|
+
// chrome, over another window). The next move with no button held is the
|
|
4584
|
+
// only signal left that the gesture is over.
|
|
4585
|
+
if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockSelDrag(); return; }
|
|
4586
|
+
const overEl = document.elementFromPoint(e.clientX, e.clientY);
|
|
4587
|
+
if (!blockSelDrag.dragging) {
|
|
4588
|
+
if (blockSelDrag.originTableEl && overEl && blockSelDrag.originTableEl.contains(overEl)) return;
|
|
4589
|
+
const line = selectableLineAtPoint(e.clientX, e.clientY);
|
|
4590
|
+
if (line === null || line === blockSelDrag.originLine) return;
|
|
4591
|
+
blockSelDrag.dragging = true;
|
|
4592
|
+
blockSelDrag.focusLine = line;
|
|
4593
|
+
captureBlockSelDrag(e);
|
|
4594
|
+
} else {
|
|
4595
|
+
const line = selectableLineAtPoint(e.clientX, e.clientY);
|
|
4596
|
+
// Off every block (the page margin, an overlay): keep the last block the
|
|
4597
|
+
// drag actually reached rather than collapsing the set mid-gesture.
|
|
4598
|
+
if (line !== null) blockSelDrag.focusLine = line;
|
|
4599
|
+
}
|
|
4600
|
+
e.preventDefault();
|
|
4601
|
+
// The press started a native text selection that keeps extending with the
|
|
4602
|
+
// pointer; once the gesture is a BLOCK selection the two must not both be
|
|
4603
|
+
// painted. preventDefault() on pointermove does not stop it, so it is
|
|
4604
|
+
// dropped explicitly on every frame of the drag.
|
|
4605
|
+
clearNativeTextSelection();
|
|
4606
|
+
setBlockSelection({ anchorLine: blockSelDrag.originLine, focusLine: blockSelDrag.focusLine });
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
// §4.4 entry (b). The anchor is the standing selection's own anchor, or —
|
|
4610
|
+
// entering fresh — the block that holds the caret, so Shift+Click reads as
|
|
4611
|
+
// "from where I am to here". With neither, it collapses onto the clicked
|
|
4612
|
+
// block, which is extendTo()'s own answer for a null selection.
|
|
4613
|
+
function beginShiftClickSelection(e) {
|
|
4614
|
+
if (isSelGestureChrome(e.target)) return;
|
|
4615
|
+
const line = selectableLineOf(e.target);
|
|
4616
|
+
if (line === null) return;
|
|
4617
|
+
const seed = focusedSelectableLine();
|
|
4618
|
+
// Shift+Click INSIDE the block that already holds the caret is the one
|
|
4619
|
+
// Shift+Click that must stay native: it is how a user extends a text
|
|
4620
|
+
// selection to a point, and there is no second block to take.
|
|
4621
|
+
if (!blockSelection && seed !== null && seed === line) return;
|
|
4622
|
+
// Cancels the caret placement (and the focus move that would start a
|
|
4623
|
+
// burst on the clicked block) before it happens; the trailing click is
|
|
4624
|
+
// consumed by the flag.
|
|
4625
|
+
e.preventDefault();
|
|
4626
|
+
blockSelClickSuppressed = true;
|
|
4627
|
+
clearNativeTextSelection();
|
|
4628
|
+
const base = blockSelection || (seed === null ? null : { anchorLine: seed, focusLine: seed });
|
|
4629
|
+
setBlockSelection(selectionLib.extendTo(base, line));
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
// §4.4 entry (c) / its extension. Returns true when it owned the key.
|
|
4633
|
+
// stepFocus() skips blocks that own no source line, so every press MOVES —
|
|
4634
|
+
// Task 1 carry 3's "the first Shift+↓ does nothing, the second one moves".
|
|
4635
|
+
function stepSelectionFocus(dir) {
|
|
4636
|
+
if (!selectionLib) return false;
|
|
4637
|
+
if (blockSelection) {
|
|
4638
|
+
setBlockSelection(selectionLib.stepFocus(blockSelection, blocks, dir));
|
|
4639
|
+
return true;
|
|
4640
|
+
}
|
|
4641
|
+
// Entering fresh: the block that holds the roving focus, or — once a
|
|
4642
|
+
// clear has blurred it (see lastSelectionFocusLine) — the line the last
|
|
4643
|
+
// selection ended on, provided it still names a block that owns a line.
|
|
4644
|
+
let seed = focusedSelectableLine();
|
|
4645
|
+
if (seed === null && lastSelectionFocusLine !== null) {
|
|
4646
|
+
const rec = blocks.find((b) => b.startLine === lastSelectionFocusLine &&
|
|
4647
|
+
b.endLine >= b.startLine);
|
|
4648
|
+
if (rec) seed = rec.startLine;
|
|
4649
|
+
}
|
|
4650
|
+
if (seed === null) return false;
|
|
4651
|
+
setBlockSelection(selectionLib.stepFocus({ anchorLine: seed, focusLine: seed }, blocks, dir));
|
|
4652
|
+
return true;
|
|
4653
|
+
}
|
|
4654
|
+
|
|
3311
4655
|
// ── T7: surviving a commit that renumbers every block id ───────────────
|
|
3312
4656
|
// A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
|
|
3313
4657
|
// that resolution can commit a DIFFERENT block's dirty editor, re-render,
|
|
@@ -3662,7 +5006,13 @@
|
|
|
3662
5006
|
if (res.unsupported[i] !== 'MULTILINE') return false;
|
|
3663
5007
|
}
|
|
3664
5008
|
if (opts && opts.columnOnly) return true;
|
|
3665
|
-
|
|
5009
|
+
// S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
|
|
5010
|
+
// one run and every one of them has to clear §4.1's multi-line gate, not
|
|
5011
|
+
// just the one the ⠿ was pressed on. An empty array is the same question as
|
|
5012
|
+
// no target at all (nobody is being rewritten).
|
|
5013
|
+
const targetEls = targetEl === null || targetEl === undefined
|
|
5014
|
+
? [] : (Array.isArray(targetEl) ? targetEl : [targetEl]);
|
|
5015
|
+
if (!targetEls.length) return multi.length === 0;
|
|
3666
5016
|
// T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
|
|
3667
5017
|
// `multiLineBlockIds` reports a '\n' in the item's surface text, which
|
|
3668
5018
|
// sees a LAZY continuation and is blind to a markdown HARD BREAK (two
|
|
@@ -3675,12 +5025,15 @@
|
|
|
3675
5025
|
// Chromium leaves when the last character is deleted, and an emptied item
|
|
3676
5026
|
// must stay removable). `multi` is kept as well — it costs nothing and
|
|
3677
5027
|
// covers any surface newline that is not a line-range fact.
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
5028
|
+
for (let t = 0; t < targetEls.length; t++) {
|
|
5029
|
+
const targetRaw = targetEls[t].getAttribute('data-block-id');
|
|
5030
|
+
const targetRec = blocks.find((b) => b.id === Number(targetRaw));
|
|
5031
|
+
if (targetRec && targetRec.endLine > targetRec.startLine) return false;
|
|
5032
|
+
// getAttribute() strings on both sides — the same convention
|
|
5033
|
+
// unsupportedByLi[].blockId uses.
|
|
5034
|
+
if (multi.indexOf(targetRaw) !== -1) return false;
|
|
5035
|
+
}
|
|
5036
|
+
return true;
|
|
3684
5037
|
}
|
|
3685
5038
|
|
|
3686
5039
|
// Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
|
|
@@ -4157,6 +5510,222 @@
|
|
|
4157
5510
|
return true;
|
|
4158
5511
|
}
|
|
4159
5512
|
|
|
5513
|
+
// ── S3 Task 7: §3.5's batch Tab / Shift+Tab over a standing selection ──
|
|
5514
|
+
//
|
|
5515
|
+
// The whole task in one sentence: compute the delta ONCE, from the member
|
|
5516
|
+
// with the MINIMUM old indent, apply it to the whole set, and only THEN
|
|
5517
|
+
// clamp per item. Running the single-item arithmetic per member instead
|
|
5518
|
+
// breaks the sibling relationships the user selected — §3.5's own worked
|
|
5519
|
+
// example is `- a / (2sp)- b / (2sp)- c` with b+c selected: per item, c's
|
|
5520
|
+
// ceiling is 2 (because b sits at 1) so c moves there and is ADOPTED as b's
|
|
5521
|
+
// child, when the correct answer is that nothing moves at all.
|
|
5522
|
+
//
|
|
5523
|
+
// And the one number is the MINIMUM AVAILABLE HEAD-ROOM across the members,
|
|
5524
|
+
// not the head-room of the shallowest one. This is review defect D1
|
|
5525
|
+
// (2026-08-31), MEASURED on 8486ecd: `- a / (2sp)- b / (2sp)- c / - d` with
|
|
5526
|
+
// b, c and d selected took its delta from d (the minimum INDENT, +1) and
|
|
5527
|
+
// applied it to all three; applyIndentClamp() then walked the operated
|
|
5528
|
+
// blocks in document order, pulled b back to its own ceiling of 1 — and
|
|
5529
|
+
// recomputed c's ceiling against the JUST-SETTLED b, so c was allowed to
|
|
5530
|
+
// stay at 2 and became b's CHILD. Only the constrained member came back.
|
|
5531
|
+
// That is the 「半移動」 §3.4 rule 4 forbids: 「段內相對關係必須保持」.
|
|
5532
|
+
//
|
|
5533
|
+
// So a member with no head-room floors the WHOLE set at zero. Three reasons
|
|
5534
|
+
// this is the branch and not "move what can move":
|
|
5535
|
+
// * it is what Shift+Tab has always done — its delta is floored by a
|
|
5536
|
+
// member already at indent 0, and T7 carry 5 records that as a
|
|
5537
|
+
// deliberate no-op for exactly this reason;
|
|
5538
|
+
// * §3.4 rule 4 requires the members' relative relationships to survive
|
|
5539
|
+
// the operation, and a partial move is precisely what breaks them;
|
|
5540
|
+
// * CHANGELOG v2.12.0 already promises it — "a set that cannot move as a
|
|
5541
|
+
// whole does not move at all rather than half-moving".
|
|
5542
|
+
//
|
|
5543
|
+
// ⚠ §3.4 rule 3's SECOND worked example is written the other way round
|
|
5544
|
+
// (「批次 Tab 選 b(1)..d(0) 時第一成員 b 已在上界 ⇒ delta 0 ⇒ 整批 no-op」 is
|
|
5545
|
+
// listed there as a FAILURE of first-member anchoring). It cannot be
|
|
5546
|
+
// satisfied at the same time as rule 4 on the D1 fixture — d moving while b
|
|
5547
|
+
// and c are clamped IS the half-move — and the review ruled for rule 4. The
|
|
5548
|
+
// b(1)..d(0) set is therefore a whole-batch no-op now, and T7's scenario for
|
|
5549
|
+
// it was migrated with that reason in its assertion message.
|
|
5550
|
+
//
|
|
5551
|
+
// spanMinIndent() is still §3.4 rule 3's anchor VALUE for applyIndentClamp()
|
|
5552
|
+
// — the bound the blocks BELOW the set are re-measured against — and that
|
|
5553
|
+
// use is unchanged. Only the set's own movement is computed here.
|
|
5554
|
+
|
|
5555
|
+
// How far ONE member may move, as a magnitude (0 or 1), never a signed
|
|
5556
|
+
// delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
|
|
5557
|
+
// indentListItem()'s own single-item rule — the list-start clause included,
|
|
5558
|
+
// so a set that opens a list cannot nest itself under the previous list's
|
|
5559
|
+
// last item — and the floor is outdentListItem()'s (indent 0 cannot rise).
|
|
5560
|
+
// Both read PRE-move indents, which is §3.4's global convention ("the
|
|
5561
|
+
// operated block's indent" always means the value before the operation), and
|
|
5562
|
+
// it is what makes the minimum below well-defined: every member is measured
|
|
5563
|
+
// against the document as it stands, not against members already moved.
|
|
5564
|
+
function memberIndentHeadroom(el, dir) {
|
|
5565
|
+
const self = liAttrs(el);
|
|
5566
|
+
if (!self) return 0;
|
|
5567
|
+
if (dir < 0) return self.indent > 0 ? 1 : 0;
|
|
5568
|
+
if (self.listStart) return 0;
|
|
5569
|
+
const all = allBlockEls();
|
|
5570
|
+
const i = all.indexOf(el);
|
|
5571
|
+
if (i < 0) return 0;
|
|
5572
|
+
const prev = liAttrs(all[i - 1]);
|
|
5573
|
+
const max = prev ? prev.indent + 1 : 0;
|
|
5574
|
+
return Math.max(0, Math.min(1, max - self.indent));
|
|
5575
|
+
}
|
|
5576
|
+
|
|
5577
|
+
// How far the WHOLE set may move, as one SIGNED number: the smallest
|
|
5578
|
+
// head-room any member has, in `dir`'s direction. Zero for an empty set and
|
|
5579
|
+
// zero the moment one member cannot move, which is the D1 ruling.
|
|
5580
|
+
function batchIndentDelta(liEls, dir) {
|
|
5581
|
+
const els = liEls || [];
|
|
5582
|
+
if (!els.length) return 0;
|
|
5583
|
+
let room = 1;
|
|
5584
|
+
for (let i = 0; i < els.length; i++) {
|
|
5585
|
+
const r = memberIndentHeadroom(els[i], dir);
|
|
5586
|
+
if (r < room) room = r;
|
|
5587
|
+
if (room === 0) return 0;
|
|
5588
|
+
}
|
|
5589
|
+
return dir < 0 ? -room : room;
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
// The li half. One mutation, one clamp, one commit — never a loop over the
|
|
5593
|
+
// members, for the reason the whole batch layer exists: a loop re-renders
|
|
5594
|
+
// between items and invalidates every id in between.
|
|
5595
|
+
async function indentListItemsBySelection(liEls, recs, dir) {
|
|
5596
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
5597
|
+
const run = batchRunOf(liEls);
|
|
5598
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
5599
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so a
|
|
5600
|
+
// hard-wrapped member is a legal TARGET here — the same deviation the caret
|
|
5601
|
+
// Tab's own gate documents, and in a batch every member is a target.
|
|
5602
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { columnOnly: true })) {
|
|
5603
|
+
refuseStructuralListEdit(); return;
|
|
5604
|
+
}
|
|
5605
|
+
const oldIndent = spanMinIndent(liEls);
|
|
5606
|
+
const delta = batchIndentDelta(liEls, dir);
|
|
5607
|
+
// A zero delta is a COMPLETE no-op — nothing mutated, nothing committed,
|
|
5608
|
+
// file byte-identical, the set left standing — exactly what the single-item
|
|
5609
|
+
// Tab does at its own boundary, and NOT a refusal (no banner: the spec's
|
|
5610
|
+
// 「靜默不動作是缺陷」 is about refusals, and this is the documented
|
|
5611
|
+
// boundary answer §3.5 gives for a set at its ceiling). On §3.5's b+c
|
|
5612
|
+
// example this IS the right answer, and it is the answer per-item maths
|
|
5613
|
+
// cannot give; after D1 it is also the answer whenever ANY member is at
|
|
5614
|
+
// its ceiling, which is what keeps the members' relative order intact.
|
|
5615
|
+
if (delta === 0) return;
|
|
5616
|
+
const first = recs[0];
|
|
5617
|
+
const last = recs[recs.length - 1];
|
|
5618
|
+
mutateListRun(() => {
|
|
5619
|
+
liEls.forEach((el) => setBlockIndent(el,
|
|
5620
|
+
Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + delta)));
|
|
5621
|
+
// ONE call for the whole set, never one per member: clampIndents() takes
|
|
5622
|
+
// an ARRAY of operated indices precisely so §3.4 rule 3 computes one
|
|
5623
|
+
// segment delta for the blocks below instead of N independent clamps, and
|
|
5624
|
+
// its rule 1 then walks the operated blocks in document order so each is
|
|
5625
|
+
// measured against the member above it that the same pass just settled.
|
|
5626
|
+
applyIndentClamp(run, liEls, oldIndent);
|
|
5627
|
+
});
|
|
5628
|
+
// Re-derived AFTER the mutation, like the caret Tab's: an indent change can
|
|
5629
|
+
// move a block between runs.
|
|
5630
|
+
const liveRun = listRunOf(liEls[0]);
|
|
5631
|
+
if (!liveRun.length) return;
|
|
5632
|
+
// Column-only: nothing's CONTENT moved, so every block in the span — the
|
|
5633
|
+
// members included — is a bystander whose source bytes must come back
|
|
5634
|
+
// untouched.
|
|
5635
|
+
const carry = bystanderCarryOver(liveRun, null);
|
|
5636
|
+
// §3.3's collapse, declared immediately before the commit's render (Task 5
|
|
5637
|
+
// carry 5) and only when a set was standing (Task 6 carry 6). A
|
|
5638
|
+
// column-only edit changes no line COUNT and moves no line, so the members
|
|
5639
|
+
// keep exactly the range they had.
|
|
5640
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5641
|
+
await commitListStructure(liveRun, null, false, { carryOver: carry });
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5644
|
+
// The non-li half — §3.5's heading row, batched: Tab lowers a heading one
|
|
5645
|
+
// level (clamped to H6), Shift+Tab raises it (clamped to H1), and a
|
|
5646
|
+
// paragraph / quote / code member is a true no-op.
|
|
5647
|
+
//
|
|
5648
|
+
// The span is rewritten IN PLACE inside its own line range: each heading
|
|
5649
|
+
// member's line is re-emitted at the new depth and every other line —
|
|
5650
|
+
// separators and non-heading members alike — comes back verbatim. That is
|
|
5651
|
+
// what makes 「段落 no-op」 a real no-op instead of a re-serialization that
|
|
5652
|
+
// could move bytes nobody asked it to, and it is why this is ONE
|
|
5653
|
+
// commitRangeEdit and therefore one undo op.
|
|
5654
|
+
async function changeHeadingDepthsInSpan(els, recs, dir) {
|
|
5655
|
+
const first = recs[0];
|
|
5656
|
+
const last = recs[recs.length - 1];
|
|
5657
|
+
const span = lines.slice(first.startLine - 1, last.endLine);
|
|
5658
|
+
let changed = false;
|
|
5659
|
+
for (let i = 0; i < recs.length; i++) {
|
|
5660
|
+
if (recs[i].type !== 'heading') continue;
|
|
5661
|
+
const at = recs[i].startLine - first.startLine;
|
|
5662
|
+
const cur = span[at];
|
|
5663
|
+
// headingDepthOf() answers 1 for ANY line, matched or not, so a SETEXT
|
|
5664
|
+
// heading ('Title' over '====') would be handed a '#' it never asked for
|
|
5665
|
+
// and its underline left behind. Only a real ATX line is rewritten.
|
|
5666
|
+
if (typeof cur !== 'string' || !/^#{1,6}(\s|$)/.test(cur)) continue;
|
|
5667
|
+
const depth = headingDepthOf(cur);
|
|
5668
|
+
const next = Math.max(1, Math.min(6, depth + dir));
|
|
5669
|
+
if (next === depth) continue;
|
|
5670
|
+
span[at] = withHeadingDepth(cur, next);
|
|
5671
|
+
changed = true;
|
|
5672
|
+
}
|
|
5673
|
+
// Every member was a paragraph, or every heading already sat at its clamp:
|
|
5674
|
+
// §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
|
|
5675
|
+
if (!changed) return;
|
|
5676
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
5677
|
+
first.startLine, last.endLine, span.join('\n'));
|
|
5678
|
+
if (result.op === null) return;
|
|
5679
|
+
const prevLines = lines;
|
|
5680
|
+
lines = result.lines;
|
|
5681
|
+
// §3.3's collapse: a depth change rewrites no line COUNT, so the set
|
|
5682
|
+
// collapses onto the lines it already held.
|
|
5683
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5684
|
+
const okRender = await safeRerenderAll();
|
|
5685
|
+
if (!okRender) {
|
|
5686
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
5687
|
+
}
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
// The keydown entry point. The "grip" is the roving focus holder, which is a
|
|
5691
|
+
// member of the set by construction, so resolveGutterOperands() answers
|
|
5692
|
+
// 'batch' and hands back the whole span — together with the shared preamble
|
|
5693
|
+
// every ⠿ operation already goes through (switchAwayFrom(), the re-resolve
|
|
5694
|
+
// after a commit that re-rendered, the no-source-line refusal, the emptiness
|
|
5695
|
+
// and contiguity gates). Membership resolves by REFERENCE against the live
|
|
5696
|
+
// `blocks`, which is exactly why the record must come from there and not from
|
|
5697
|
+
// a fresh find() (Task 1 carry 4).
|
|
5698
|
+
async function tabSelection(dir) {
|
|
5699
|
+
const focusEl = selectionFocusBlockEl();
|
|
5700
|
+
if (!focusEl) return;
|
|
5701
|
+
const operands = await resolveGutterOperands(focusEl);
|
|
5702
|
+
if (!operands) return;
|
|
5703
|
+
const els = operands.els;
|
|
5704
|
+
const recs = operands.recs;
|
|
5705
|
+
const shape = spanListKinds(els);
|
|
5706
|
+
// §3.6's 2026-08-31 ruling, inherited rather than re-invented. NOTE: this
|
|
5707
|
+
// CONTRADICTS the S3 plan's Task 7 text ("a batch containing both kinds
|
|
5708
|
+
// applies each rule to its own kind") — see the Task 7 carry. A mixed span
|
|
5709
|
+
// would need the run's survivors re-serialized at the same time as lines
|
|
5710
|
+
// outside the run are rewritten, i.e. two commits and therefore two undo
|
|
5711
|
+
// ops, which §3.4's 「一次使用者手勢 = 恰好一個 undo op」 forbids.
|
|
5712
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
5713
|
+
if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
|
|
5714
|
+
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
// §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
|
|
5718
|
+
// the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
|
|
5719
|
+
// is written. deleteBlockViaGutter() already resolves §3.3's membership for
|
|
5720
|
+
// itself and routes an all-li span to deleteListItemsViaGutter(), so the key
|
|
5721
|
+
// is one call — every refusal, every collapse and the one-undo-op guarantee
|
|
5722
|
+
// come with it for free rather than being restated here.
|
|
5723
|
+
async function deleteSelection() {
|
|
5724
|
+
const focusEl = selectionFocusBlockEl();
|
|
5725
|
+
if (!focusEl) return;
|
|
5726
|
+
await deleteBlockViaGutter(focusEl);
|
|
5727
|
+
}
|
|
5728
|
+
|
|
4160
5729
|
// ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
|
|
4161
5730
|
// Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
|
|
4162
5731
|
// 8. Structurally different from Task 4's whole-list handleListKeydown()
|
|
@@ -4183,6 +5752,10 @@
|
|
|
4183
5752
|
// no-source-line callers pass their own text instead of a second function.
|
|
4184
5753
|
function refuseStructuralListEdit(message) {
|
|
4185
5754
|
showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
|
|
5755
|
+
// Review recommendation 5: mark it, so the next SUCCESSFUL render clears
|
|
5756
|
+
// it (dismissRefusalBanner(), called from rerenderAll()). Set after the
|
|
5757
|
+
// showBanner() call, which resets the flag for every other caller.
|
|
5758
|
+
activeBannerIsRefusal = true;
|
|
4186
5759
|
}
|
|
4187
5760
|
|
|
4188
5761
|
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
@@ -5019,8 +6592,9 @@
|
|
|
5019
6592
|
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
5020
6593
|
// user can actually see and aim for. Both grips are overlay elements
|
|
5021
6594
|
// `position: fixed`-appended to document.body — never DOM CHILDREN of a
|
|
5022
|
-
// contenteditable cell, even though the row grip
|
|
5023
|
-
//
|
|
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
|
|
5024
6598
|
// note in the "Notion-style row/column grip handles" section below). So —
|
|
5025
6599
|
// unlike the old zones, which
|
|
5026
6600
|
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
@@ -5333,20 +6907,46 @@
|
|
|
5333
6907
|
// listener (wired near the bottom of this file) that already drives
|
|
5334
6908
|
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
5335
6909
|
// contract this reuses.
|
|
5336
|
-
//
|
|
5337
|
-
//
|
|
5338
|
-
//
|
|
5339
|
-
//
|
|
5340
|
-
// grip:
|
|
5341
|
-
//
|
|
5342
|
-
//
|
|
5343
|
-
//
|
|
5344
|
-
// row
|
|
5345
|
-
//
|
|
5346
|
-
//
|
|
5347
|
-
//
|
|
5348
|
-
//
|
|
5349
|
-
//
|
|
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.
|
|
5350
6950
|
// Either way the grip's hit rect DOES overlap the insert bubble's hit
|
|
5351
6951
|
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
5352
6952
|
// own axis). Non-intersection via rect separation is no longer possible
|
|
@@ -5425,12 +7025,16 @@
|
|
|
5425
7025
|
// gripCenter(), which jump straight to the grip's own coordinates) could
|
|
5426
7026
|
// ever land on it; a real mouse gesture could not.
|
|
5427
7027
|
//
|
|
5428
|
-
// Current geometry (
|
|
5429
|
-
//
|
|
5430
|
-
//
|
|
5431
|
-
//
|
|
5432
|
-
//
|
|
5433
|
-
//
|
|
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.
|
|
5434
7038
|
//
|
|
5435
7039
|
// Review fix (Important, first pass over-permissive): the first version of
|
|
5436
7040
|
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
@@ -5443,14 +7047,30 @@
|
|
|
5443
7047
|
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
5444
7048
|
// pointInColGripZone() below) instead of the whole table.
|
|
5445
7049
|
//
|
|
5446
|
-
// What each keep-zone covers TODAY
|
|
5447
|
-
//
|
|
5448
|
-
//
|
|
5449
|
-
//
|
|
5450
|
-
//
|
|
5451
|
-
//
|
|
5452
|
-
//
|
|
5453
|
-
// 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.
|
|
5454
7074
|
// A pointer outside either grip's own zone is a genuine exit and still
|
|
5455
7075
|
// hides the grip via hideTableGrips(), same as before. Neither fix touches
|
|
5456
7076
|
// either grip's size or z-index, so the click-priority guarantee (bubble
|
|
@@ -5470,15 +7090,17 @@
|
|
|
5470
7090
|
function pointInRowGripZone(x, y) {
|
|
5471
7091
|
if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
|
|
5472
7092
|
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
//
|
|
5476
|
-
//
|
|
5477
|
-
//
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
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);
|
|
5482
7104
|
}
|
|
5483
7105
|
|
|
5484
7106
|
function pointInColGripZone(x, y) {
|
|
@@ -5507,9 +7129,10 @@
|
|
|
5507
7129
|
function updateTableEdgeGrips(x, y, target) {
|
|
5508
7130
|
// Both grips are `position: fixed` overlays appended to document.body
|
|
5509
7131
|
// (same as the hover-insert bubbles) rather than descendants of the
|
|
5510
|
-
// table —
|
|
5511
|
-
// border
|
|
5512
|
-
//
|
|
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
|
|
5513
7136
|
// the grip itself, `target` is the grip and is no
|
|
5514
7137
|
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
5515
7138
|
// Without this guard, that transition would hit the "nothing found"
|
|
@@ -5544,8 +7167,11 @@
|
|
|
5544
7167
|
// Row grip: every row, including the header — the first row of a
|
|
5545
7168
|
// markdown table IS the header, so any row must be draggable to the
|
|
5546
7169
|
// top to become it. ONE position rule for all of them (user acceptance:
|
|
5547
|
-
// 「grip 位置都一樣」) —
|
|
5548
|
-
//
|
|
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
|
|
5549
7175
|
// one exception is a header-only table (no body rows): its single row
|
|
5550
7176
|
// is thead's only row, and dragging it away would empty the thead —
|
|
5551
7177
|
// serializeTable() would degrade it and the user's table would vanish
|
|
@@ -5554,13 +7180,19 @@
|
|
|
5554
7180
|
gripRowTableEl = tableEl;
|
|
5555
7181
|
gripRowEl = rowEl;
|
|
5556
7182
|
const r = rowEl.getBoundingClientRect();
|
|
5557
|
-
// Fallback
|
|
5558
|
-
// (
|
|
5559
|
-
//
|
|
5560
|
-
// `hidden = false` assignment below takes effect.
|
|
5561
|
-
|
|
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.
|
|
5562
7194
|
const gh = rowGrip.offsetHeight || 28;
|
|
5563
|
-
rowGrip.style.left =
|
|
7195
|
+
rowGrip.style.left = tableRect.left + 'px';
|
|
5564
7196
|
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
5565
7197
|
rowGrip.hidden = false;
|
|
5566
7198
|
} else {
|
|
@@ -5914,6 +7546,820 @@
|
|
|
5914
7546
|
currentBurst.history.snap('drag-col');
|
|
5915
7547
|
}
|
|
5916
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
|
+
|
|
5917
8363
|
document.addEventListener('pointerdown', (e) => {
|
|
5918
8364
|
if (e.button !== 0) return;
|
|
5919
8365
|
if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
|
|
@@ -5924,6 +8370,16 @@
|
|
|
5924
8370
|
// capture) BEFORE starting a new one, so a stuck drag can never survive
|
|
5925
8371
|
// into the next gesture and a fresh pointerdown always starts clean.
|
|
5926
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();
|
|
8377
|
+
// S3 Task 4: same defensive reasoning one line up, for the block-selection
|
|
8378
|
+
// press — and the flag that suppresses a gesture's own trailing click is
|
|
8379
|
+
// re-armed here so a gesture whose click never arrived cannot swallow the
|
|
8380
|
+
// next one.
|
|
8381
|
+
blockSelClickSuppressed = false;
|
|
8382
|
+
if (blockSelDrag) endBlockSelDrag();
|
|
5927
8383
|
const hit = hitTestGrip(e.target);
|
|
5928
8384
|
// A click on a DIFFERENT zone (or entirely outside any zone) dismisses
|
|
5929
8385
|
// whatever menu is already open, same "any other click closes the ⠿
|
|
@@ -5939,7 +8395,38 @@
|
|
|
5939
8395
|
// teMenuKind alone, or that highlight would survive until
|
|
5940
8396
|
// resolveBurst() instead of clearing on the next click.
|
|
5941
8397
|
if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
|
|
5942
|
-
if (!hit)
|
|
8398
|
+
if (!hit) {
|
|
8399
|
+
// S3 Task 4 (recon hazard 1): this listener already fires on EVERY left
|
|
8400
|
+
// click and is a no-op whenever hitTestGrip() finds nothing — which is
|
|
8401
|
+
// exactly the branch the block-selection gestures belong on. Arming
|
|
8402
|
+
// them HERE, inside the incumbent handler, is what makes "a grip hit
|
|
8403
|
+
// still wins" true by construction: a second, competing pointerdown
|
|
8404
|
+
// listener would have to re-derive the hit test, and would race this
|
|
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;
|
|
8426
|
+
if (e.shiftKey) { beginShiftClickSelection(e); return; }
|
|
8427
|
+
armBlockSelDrag(e);
|
|
8428
|
+
return;
|
|
8429
|
+
}
|
|
5943
8430
|
e.preventDefault();
|
|
5944
8431
|
tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
|
|
5945
8432
|
pointerId: e.pointerId, captureEl: e.target };
|
|
@@ -5989,7 +8476,38 @@
|
|
|
5989
8476
|
else updateColDropIndicator(e.clientX);
|
|
5990
8477
|
});
|
|
5991
8478
|
|
|
8479
|
+
// S3 Task 4: the block-selection drag's own pointermove. Registered after
|
|
8480
|
+
// the table drag's (above), and bails while `tePointer` is set, so a grip
|
|
8481
|
+
// gesture is never fought over. Not folded into that listener because its
|
|
8482
|
+
// very first line is `if (!tePointer) return;` — the state this one runs in.
|
|
8483
|
+
document.addEventListener('pointermove', (e) => {
|
|
8484
|
+
updateBlockSelDrag(e);
|
|
8485
|
+
});
|
|
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
|
+
|
|
5992
8496
|
document.addEventListener('pointerup', async (e) => {
|
|
8497
|
+
// S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
|
|
8498
|
+
// this file, so the block-selection drag ends on the pointer events, the
|
|
8499
|
+
// same skeleton the table drag uses. Before the `!tePointer` bail: a
|
|
8500
|
+
// selection drag is armed precisely when no grip gesture is in flight.
|
|
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();
|
|
5993
8511
|
if (!tePointer) return;
|
|
5994
8512
|
const st = tePointer;
|
|
5995
8513
|
releaseTeCapture(st);
|
|
@@ -6035,6 +8553,14 @@
|
|
|
6035
8553
|
// actually moved mid-drag, only the indicator line).
|
|
6036
8554
|
document.addEventListener('pointercancel', () => {
|
|
6037
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();
|
|
8559
|
+
// S3 Task 4: the same abort ends a block-selection drag. The selection it
|
|
8560
|
+
// has built so far STANDS (§4.4: nothing but Escape and a plain click
|
|
8561
|
+
// clears) — what must not survive is the live drag, or the next stray
|
|
8562
|
+
// pointer move would keep extending a gesture the user already ended.
|
|
8563
|
+
endBlockSelDrag();
|
|
6038
8564
|
});
|
|
6039
8565
|
|
|
6040
8566
|
// Review fix (Critical): the whole BROWSER WINDOW losing focus mid-
|
|
@@ -6046,6 +8572,13 @@
|
|
|
6046
8572
|
// when no gesture is in flight, so this is safe to fire on every blur.
|
|
6047
8573
|
window.addEventListener('blur', () => {
|
|
6048
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();
|
|
8578
|
+
// S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
|
|
8579
|
+
// drag in flight when focus left is exactly as undeliverable-pointerup as
|
|
8580
|
+
// a row drag's, so it is torn down here too.
|
|
8581
|
+
endBlockSelDrag();
|
|
6049
8582
|
});
|
|
6050
8583
|
|
|
6051
8584
|
// ── Phase-2 Task 4: floating selection toolbar (bold/italic/code/link) ──
|
|
@@ -6386,6 +8919,12 @@
|
|
|
6386
8919
|
(e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
|
|
6387
8920
|
});
|
|
6388
8921
|
document.addEventListener('click', async (e) => {
|
|
8922
|
+
// S3 Task 4: the trailing click of a gesture that already answered this
|
|
8923
|
+
// press itself (a drag across a block boundary, a Shift+Click). A drag
|
|
8924
|
+
// released in another block would otherwise hit the §4.4 exit rule
|
|
8925
|
+
// below and clear the set it just built; one released on the page
|
|
8926
|
+
// margin would hit the outside-a-block switchAwayFrom() instead.
|
|
8927
|
+
if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
|
|
6389
8928
|
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); closeInsertMenu(); return; }
|
|
6390
8929
|
// showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
|
|
6391
8930
|
// .ed-block — so without this guard a click on the banner's own
|
|
@@ -6489,6 +9028,15 @@
|
|
|
6489
9028
|
// Any other click closes an already-open ⠿ menu / + menu.
|
|
6490
9029
|
if (gutterMenuBlockEl) closeGutterMenu();
|
|
6491
9030
|
if (insertMenuBlockEl) closeInsertMenu();
|
|
9031
|
+
// S3 Task 4, §4.4 exit: a click INSIDE any block, without Shift, clears
|
|
9032
|
+
// the whole set. Deliberately below the ⠿/+/menu/checkbox branches
|
|
9033
|
+
// above, which all return early — §3.3 decides batch-vs-single by
|
|
9034
|
+
// whether the GRIP is inside the set, so a grip click must not clear it
|
|
9035
|
+
// first. A click OUTSIDE every block is deliberately not an exit: §4.4
|
|
9036
|
+
// lists exactly Escape and this one. clearBlockSelection() steals no
|
|
9037
|
+
// focus (Task 3 carry 7), so the caret still lands where the user
|
|
9038
|
+
// clicked.
|
|
9039
|
+
if (blockSelection && !e.shiftKey && e.target.closest('.ed-block')) clearBlockSelection();
|
|
6492
9040
|
if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
|
|
6493
9041
|
let blockEl = e.target.closest('.ed-block');
|
|
6494
9042
|
// v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
|
|
@@ -6589,6 +9137,14 @@
|
|
|
6589
9137
|
const r = stack.undo(lines);
|
|
6590
9138
|
if (!r) return;
|
|
6591
9139
|
lines = r.lines;
|
|
9140
|
+
// S3 Task 5 (§4.4): undo/redo ALWAYS clears the block selection.
|
|
9141
|
+
// `UndoStack`'s op is exactly {startLine, endLine, before, after}
|
|
9142
|
+
// (lineops.js) and carries no selection state, so there is nothing to
|
|
9143
|
+
// restore a set to — and Task 5's rebuild would otherwise keep the old
|
|
9144
|
+
// line range standing over a document that just changed underneath it.
|
|
9145
|
+
// DECLARED rather than cleared outright so the rollback below leaves a
|
|
9146
|
+
// standing selection alone when the render fails.
|
|
9147
|
+
declareSelectionRange(null);
|
|
6592
9148
|
const ok = await safeRerenderAll();
|
|
6593
9149
|
if (!ok) {
|
|
6594
9150
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
@@ -6604,6 +9160,7 @@
|
|
|
6604
9160
|
const r = stack.redo(lines);
|
|
6605
9161
|
if (!r) return;
|
|
6606
9162
|
lines = r.lines;
|
|
9163
|
+
declareSelectionRange(null); // §4.4, same as undo() above
|
|
6607
9164
|
const ok = await safeRerenderAll();
|
|
6608
9165
|
if (!ok) {
|
|
6609
9166
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
@@ -6614,36 +9171,71 @@
|
|
|
6614
9171
|
|
|
6615
9172
|
// ── global key handling ─────────────────────────────────────────────────
|
|
6616
9173
|
document.addEventListener('keydown', (e) => {
|
|
6617
|
-
//
|
|
6618
|
-
//
|
|
6619
|
-
//
|
|
6620
|
-
// (
|
|
6621
|
-
//
|
|
6622
|
-
//
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
//
|
|
6629
|
-
//
|
|
6630
|
-
//
|
|
6631
|
-
//
|
|
6632
|
-
//
|
|
6633
|
-
//
|
|
6634
|
-
//
|
|
6635
|
-
//
|
|
6636
|
-
//
|
|
6637
|
-
//
|
|
6638
|
-
//
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6641
|
-
//
|
|
6642
|
-
//
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
9174
|
+
// ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
|
|
9175
|
+
// A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
|
|
9176
|
+
// explicit about that). The two target-based short-circuits further down
|
|
9177
|
+
// (.ed-wys-cell, .ed-wys-armed) `return` for EVERY key, so the gutter /
|
|
9178
|
+
// insert menu's own Escape branch — which sits BELOW them — was
|
|
9179
|
+
// unreachable whenever an armed surface held focus. And it always does
|
|
9180
|
+
// while a ⠿ menu is open: wireBlockSelection()'s mousedown
|
|
9181
|
+
// preventDefault() deliberately keeps focus inside the burst so a dirty
|
|
9182
|
+
// block's own ⠿ click cannot race its blur-commit.
|
|
9183
|
+
//
|
|
9184
|
+
// Measured on 2026-08-30 against v2.11.0: typing " EDITED" into a
|
|
9185
|
+
// paragraph, opening its ⠿ menu and pressing Escape reached
|
|
9186
|
+
// handleBurstKeydown() -> revertBurstAndEnd() — the paragraph went back
|
|
9187
|
+
// to "alpha", the uncommitted edit was destroyed, AND the menu stayed on
|
|
9188
|
+
// screen. Exactly inverted from what the user asked for.
|
|
9189
|
+
//
|
|
9190
|
+
// ⚠ CORRECTED 2026-08-31 (review recommendation 6). Until this date the
|
|
9191
|
+
// comment here read "the two Escape branches immediately below ... are
|
|
9192
|
+
// deliberately kept: they still carry their non-Escape duties". They had
|
|
9193
|
+
// none — both were gated on `&& e.key === 'Escape'` and both conditions
|
|
9194
|
+
// are re-asked verbatim in this prologue, which returns, so neither could
|
|
9195
|
+
// ever run again. They have been DELETED and the reasoning they carried
|
|
9196
|
+
// is folded in here, which is where the live checks are:
|
|
9197
|
+
//
|
|
9198
|
+
// * the row drag wins the keystroke ahead of everything else, Ctrl+S
|
|
9199
|
+
// included, so a drag in flight is never resolved by a save;
|
|
9200
|
+
// * the edge-menu condition is `(teMenuKind || teHighlightEls.length)`,
|
|
9201
|
+
// NOT `teMenuKind` alone. That widening was made in S0 (final review
|
|
9202
|
+
// I1) for the HEADER grip, whose click deliberately produces a
|
|
9203
|
+
// highlight with no menu (its only item, "delete row", cannot apply to
|
|
9204
|
+
// a header), so teMenuKind stays null. Gated on teMenuKind alone the
|
|
9205
|
+
// Escape fell through to handleTableCellKeydown()'s own Escape branch
|
|
9206
|
+
// -> revertTableBurstAndEnd(), throwing away everything typed into the
|
|
9207
|
+
// burst — so the header row would have been the one place where
|
|
9208
|
+
// dismissing a selection is destructive. Do not narrow it back.
|
|
9209
|
+
//
|
|
9210
|
+
// The `closeGutterMenu()` source-presence guard in
|
|
9211
|
+
// test/editor-client.test.js is NOT affected: it matches the literal
|
|
9212
|
+
// `e.key === 'Escape') { e.preventDefault(); closeGutterMenu();`, which is
|
|
9213
|
+
// the THIRD Escape branch, much further down in this same listener, and
|
|
9214
|
+
// that one is still live for a keystroke this prologue lets through.
|
|
9215
|
+
//
|
|
9216
|
+
// Placement note: this sits ABOVE the `.ed-raw` textarea bail below, which
|
|
9217
|
+
// is where the two now-deleted Escape branches sat too. Harmless for the raw
|
|
9218
|
+
// editor: its own per-instance keydown listener runs in the TARGET phase,
|
|
9219
|
+
// before this document-level one, so its Escape has already been handled;
|
|
9220
|
+
// and the one menu item that opens a raw editor (MD 原始碼) calls
|
|
9221
|
+
// closeGutterMenu() first, so gutterMenuBlockEl is null by then.
|
|
9222
|
+
if (e.key === 'Escape') {
|
|
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; }
|
|
9229
|
+
if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
|
|
9230
|
+
if (gutterMenuBlockEl || insertMenuBlockEl) {
|
|
9231
|
+
e.preventDefault();
|
|
9232
|
+
closeGutterMenu();
|
|
9233
|
+
closeInsertMenu();
|
|
9234
|
+
return;
|
|
9235
|
+
}
|
|
9236
|
+
if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
|
|
9237
|
+
// Nothing above owns this Escape — fall through to the burst
|
|
9238
|
+
// short-circuits below, where Escape-reverts-the-burst still belongs.
|
|
6647
9239
|
}
|
|
6648
9240
|
|
|
6649
9241
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's') {
|
|
@@ -6672,8 +9264,30 @@
|
|
|
6672
9264
|
// heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
|
|
6673
9265
|
// cell without ending the burst) — handleTableCellKeydown() owns that
|
|
6674
9266
|
// entire surface, mirroring handleBurstKeydown() just below.
|
|
9267
|
+
// S3: undo/redo is the one pair of keys the two short-circuits below must
|
|
9268
|
+
// NOT claim when there is no burst to own them. resolveBurst()'s zero-edit
|
|
9269
|
+
// path calls endBurstWithoutResolve() and returns WITHOUT re-rendering, so
|
|
9270
|
+
// after a Ctrl+S on an untouched surface `currentBurst` is null while that
|
|
9271
|
+
// surface still holds native focus and its .ed-wys-armed / .ed-wys-cell
|
|
9272
|
+
// class. The next Ctrl+Z then matched a short-circuit, reached
|
|
9273
|
+
// handleBurstKeydown() / handleTableCellKeydown(), and died on their
|
|
9274
|
+
// identical `!currentBurst` bail on the first line — the global undo()
|
|
9275
|
+
// further down was never reached.
|
|
9276
|
+
//
|
|
9277
|
+
// Measured on 2026-08-30 against v2.11.0, for paragraph, li AND table:
|
|
9278
|
+
// type a character, Ctrl+S, click back in, Ctrl+S again (an ordinary habit
|
|
9279
|
+
// keystroke), Ctrl+Z — the file did not change. Pre-existing on main;
|
|
9280
|
+
// fixed here because this is the same dispatch S3 rewrites.
|
|
9281
|
+
//
|
|
9282
|
+
// Deliberately narrow: only the undo/redo keys, and only with no burst.
|
|
9283
|
+
// Every other key on an armed surface keeps going to its burst handler
|
|
9284
|
+
// exactly as before, burst or no burst.
|
|
9285
|
+
const undoRedoKey = (e.ctrlKey || e.metaKey) &&
|
|
9286
|
+
(e.key === 'z' || e.key === 'y' || (e.shiftKey && e.key === 'Z'));
|
|
9287
|
+
const burstOwnsKey = !!currentBurst || !undoRedoKey;
|
|
9288
|
+
|
|
6675
9289
|
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
6676
|
-
if (cellEl) {
|
|
9290
|
+
if (cellEl && burstOwnsKey) {
|
|
6677
9291
|
handleTableCellKeydown(e, cellEl);
|
|
6678
9292
|
return;
|
|
6679
9293
|
}
|
|
@@ -6684,11 +9298,55 @@
|
|
|
6684
9298
|
// preventDefault() for those keys), so nothing below this must run for
|
|
6685
9299
|
// it either.
|
|
6686
9300
|
const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
6687
|
-
if (wysArmedEl) {
|
|
9301
|
+
if (wysArmedEl && burstOwnsKey) {
|
|
6688
9302
|
handleBurstKeydown(e, wysArmedEl);
|
|
6689
9303
|
return;
|
|
6690
9304
|
}
|
|
6691
9305
|
|
|
9306
|
+
// S3 Task 4, §4.4 entry (c). Deliberately BELOW the two burst
|
|
9307
|
+
// short-circuits above: inside an armed surface (or a table cell)
|
|
9308
|
+
// Shift+↑↓ is the browser's own extend-the-text-selection gesture and
|
|
9309
|
+
// must stay that way. A standing selection's focus holder is a plain
|
|
9310
|
+
// `.ed-block` — neither `.ed-wys-armed` nor `.ed-wys-cell` — so the keys
|
|
9311
|
+
// reach here exactly when block selection is what they can mean.
|
|
9312
|
+
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
|
|
9313
|
+
(e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
9314
|
+
if (stepSelectionFocus(e.key === 'ArrowUp' ? -1 : 1)) { e.preventDefault(); return; }
|
|
9315
|
+
}
|
|
9316
|
+
|
|
9317
|
+
// ── S3 Task 7: §3.5's 選取集合語意 and §3.6's 「Delete 整批刪」 ─────────
|
|
9318
|
+
// Deliberately BELOW the two burst short-circuits, exactly like the
|
|
9319
|
+
// Shift+↑↓ branch above: inside an armed surface Tab indents the caret's
|
|
9320
|
+
// own item and Delete removes a CHARACTER, and inside a table cell Tab
|
|
9321
|
+
// navigates cells — three contracts this must not touch. A standing
|
|
9322
|
+
// selection puts DOM focus on a plain `.ed-block` wrapper, which is
|
|
9323
|
+
// neither `.ed-wys-armed` nor `.ed-wys-cell`, so these keys arrive here
|
|
9324
|
+
// exactly when block selection is what they can mean.
|
|
9325
|
+
//
|
|
9326
|
+
// `blockSelection` is the gate, and it is the whole guard: with no set
|
|
9327
|
+
// standing there is no focus holder either, and every key below behaves
|
|
9328
|
+
// exactly as it did pre-S3.
|
|
9329
|
+
if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
9330
|
+
if (e.key === 'Tab') {
|
|
9331
|
+
// §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
|
|
9332
|
+
// 瀏覽器焦點巡覽.
|
|
9333
|
+
e.preventDefault();
|
|
9334
|
+
tabSelection(e.shiftKey ? -1 : 1);
|
|
9335
|
+
return;
|
|
9336
|
+
}
|
|
9337
|
+
// Backspace is the same gesture as Delete, deliberately: with a set
|
|
9338
|
+
// standing, focus is on a block wrapper and not on any text surface, so
|
|
9339
|
+
// neither key can mean "delete a character" — and a user who selected
|
|
9340
|
+
// blocks and reached for Backspace meant the selection. preventDefault()
|
|
9341
|
+
// matters for its own reason here: an unhandled Backspace outside an
|
|
9342
|
+
// editable is history-navigation in some configurations.
|
|
9343
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
9344
|
+
e.preventDefault();
|
|
9345
|
+
deleteSelection();
|
|
9346
|
+
return;
|
|
9347
|
+
}
|
|
9348
|
+
}
|
|
9349
|
+
|
|
6692
9350
|
// v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
|
|
6693
9351
|
// branch above is keyed on the event target being some edit surface, and
|
|
6694
9352
|
// after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
|
|
@@ -6931,6 +9589,32 @@
|
|
|
6931
9589
|
else rowGrip.hidden = true;
|
|
6932
9590
|
return;
|
|
6933
9591
|
}
|
|
9592
|
+
// S3 Task 4 (recon hazard 3): a block-selection drag needs the same
|
|
9593
|
+
// gate, for the same reason — this listener keeps firing every frame
|
|
9594
|
+
// regardless, and would repaint the + bubbles and reposition the grips
|
|
9595
|
+
// onto whatever row the cursor is dragging across, on top of the tint.
|
|
9596
|
+
// Both are HIDDEN rather than merely left un-recomputed, so anything
|
|
9597
|
+
// already showing from the frame before the boundary was crossed does
|
|
9598
|
+
// not linger stale for the rest of the gesture.
|
|
9599
|
+
if (blockSelDrag && blockSelDrag.dragging) {
|
|
9600
|
+
hideTableInsertBubbles();
|
|
9601
|
+
hideTableGrips();
|
|
9602
|
+
return;
|
|
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
|
+
}
|
|
6934
9618
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
6935
9619
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
6936
9620
|
});
|