@helping-ai-workflow/md2doc 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +769 -178
- package/lib/editor/convert-md.js +18 -11
- package/lib/editor/inline-md.js +8 -0
- package/lib/editor/list-md.js +26 -1
- package/lib/md2doc.js +5 -1
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -434,9 +434,9 @@
|
|
|
434
434
|
// reordered span it refused 1425 of the 4067 legal drops; the clamp now
|
|
435
435
|
// answers 1411 of those, and what this still refuses is the 14 no
|
|
436
436
|
// removal-clamp can reach — a batch whose last member is shallower than the
|
|
437
|
-
// block it lands before, i.e. the INSERTION half of the move.
|
|
438
|
-
// performListItemDrop()
|
|
439
|
-
//
|
|
437
|
+
// block it lands before, i.e. the INSERTION half of the move. v3.11: those
|
|
438
|
+
// 14 no longer refuse — performListItemDrop() hands them to
|
|
439
|
+
// moveSpanAcrossLists(), whose own clamp re-anchors the landing.
|
|
440
440
|
function spanIndentsAreAnchored(items) {
|
|
441
441
|
let prevLi = null;
|
|
442
442
|
for (let i = 0; i < (items || []).length; i++) {
|
|
@@ -3445,10 +3445,37 @@
|
|
|
3445
3445
|
return out;
|
|
3446
3446
|
}
|
|
3447
3447
|
|
|
3448
|
+
// v3.11 (batch-2 §6): every list serialization goes through here, so every
|
|
3449
|
+
// caller agrees on where a LOOSE list's blank lines go — the commit, and the
|
|
3450
|
+
// line arithmetic (runLineOfBlock(), the per-li commit's lineMeta index)
|
|
3451
|
+
// that has to land on the same lines. Read off the FILE, never guessed from
|
|
3452
|
+
// the DOM: two items that were already neighbours keep whatever separated
|
|
3453
|
+
// them; a new neighbour (a deletion closed the gap, a move or a copy landed,
|
|
3454
|
+
// Enter made the item) takes the spacing of the list it joins, which the
|
|
3455
|
+
// renderer stamped from marked's own looseness as data-list-loose.
|
|
3456
|
+
function serializeListEls(els, opts) {
|
|
3457
|
+
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore }, opts || {}));
|
|
3458
|
+
}
|
|
3459
|
+
function looseBlankBefore(el, prevEl) {
|
|
3460
|
+
if (!prevEl) return false;
|
|
3461
|
+
const rec = blockRecOf(el);
|
|
3462
|
+
const prevRec = blockRecOf(prevEl);
|
|
3463
|
+
if (rec && prevRec && rec !== prevRec && blocks[blocks.indexOf(rec) - 1] === prevRec) {
|
|
3464
|
+
for (let ln = prevRec.endLine + 1; ln < rec.startLine; ln++) {
|
|
3465
|
+
if (String(lines[ln - 1]).trim() === '') return true;
|
|
3466
|
+
}
|
|
3467
|
+
return false;
|
|
3468
|
+
}
|
|
3469
|
+
const loose = (x) => x.getAttribute('data-list-loose') === '1';
|
|
3470
|
+
// A brand-new item (no record yet) carries no list of its own to ask, so
|
|
3471
|
+
// the item after it answers for the list both of them are in.
|
|
3472
|
+
return prevRec ? loose(prevEl) : (loose(prevEl) || loose(el));
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3448
3475
|
function canWysiwygForLi(blockEl) {
|
|
3449
3476
|
if (!blockEl) return false;
|
|
3450
3477
|
if (blockOwnsNoLine(blockEl)) return false;
|
|
3451
|
-
const res =
|
|
3478
|
+
const res = serializeListEls([blockEl]);
|
|
3452
3479
|
return armBlockingNames(res.unsupported).length === 0 && res.unsupportedByLi.length === 0;
|
|
3453
3480
|
}
|
|
3454
3481
|
|
|
@@ -5427,58 +5454,20 @@
|
|
|
5427
5454
|
|
|
5428
5455
|
const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
|
|
5429
5456
|
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
5430
|
-
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
5431
5457
|
const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
|
|
5432
|
-
//
|
|
5433
|
-
//
|
|
5434
|
-
|
|
5435
|
-
//
|
|
5436
|
-
//
|
|
5437
|
-
//
|
|
5438
|
-
//
|
|
5439
|
-
// owned the final wording. They are three different problems with three
|
|
5440
|
-
// OPPOSITE remedies, so one sentence for all of them sends two thirds of
|
|
5441
|
-
// the users to fix the wrong end of their own gesture:
|
|
5442
|
-
// * the SOURCE seam — the block cannot leave where it is, because the two
|
|
5443
|
-
// lists it stands between would join into one loose run. No destination
|
|
5444
|
-
// helps; the remedy is not to move it (or to move something else).
|
|
5445
|
-
// * the DESTINATION seam — the block cannot go INSIDE a list. The remedy
|
|
5446
|
-
// is to aim somewhere else, which is the exact opposite advice.
|
|
5447
|
-
// * OUT OF RUN — a list item may only move among its own §3.8 siblings.
|
|
5448
|
-
// The remedy is to stay inside its own list.
|
|
5449
|
-
// Naming follows the BATCH_*_MESSAGE convention above. None of the three is
|
|
5450
|
-
// a substring of another, of §4.3's own gate message, or of
|
|
5451
|
-
// BLOCK_MOVE_ORPHAN_MESSAGE below — which is what lets a test assert WHICH
|
|
5452
|
-
// refusal fired rather than that some banner mentioning 清單 appeared.
|
|
5453
|
-
const BLOCK_MOVE_SOURCE_SEAM_MESSAGE = '移走這個區塊會讓上下兩串清單接在一起,無法搬移';
|
|
5458
|
+
// The ⠿ drag's one remaining cross-boundary refusal: a drop onto a list
|
|
5459
|
+
// item that owns no source line (the outer item of a same-line nest such
|
|
5460
|
+
// as `- - b`) — there is no line to land in front of. v3.11 retired the
|
|
5461
|
+
// other four (source seam, destination seam inside a list, out of the run,
|
|
5462
|
+
// an orphaned landing): moveSpanAcrossLists() performs those moves instead.
|
|
5463
|
+
// Not a substring of any other refusal in this file, which is what lets a
|
|
5464
|
+
// test assert WHICH one fired.
|
|
5454
5465
|
const BLOCK_MOVE_DEST_SEAM_MESSAGE = '無法把區塊放進清單項目之間';
|
|
5455
|
-
const BLOCK_MOVE_OUT_OF_RUN_MESSAGE = '清單項目只能在所屬清單內搬移';
|
|
5456
|
-
// S4 Task 7: what is LEFT of Task 4's indent refusal, and it is 14 drops out
|
|
5457
|
-
// of 4067. §4.5's clamp is now performed (performListItemDrop() runs the
|
|
5458
|
-
// removal half of the move through clampIndents()'s `{ removed: true }`),
|
|
5459
|
-
// which answers every SOURCE-side orphan. What no removal-clamp can reach is
|
|
5460
|
-
// the INSERTION half: a BATCH whose last member is SHALLOWER than the block
|
|
5461
|
-
// it lands before leaves that block with no parent chain above it, at the
|
|
5462
|
-
// NEW index, and §3.4 has no rule for that seam. MEASURED over every legal
|
|
5463
|
-
// indent array of length 2..6 and depth 0..3, every operand-set size and
|
|
5464
|
-
// every destination the run gate admits: 4067 drops, 1425 refused by Task
|
|
5465
|
-
// 4's predicate, 14 still unanchored after the clamp — every one of them a
|
|
5466
|
-
// multi-block set breaking at the block immediately after the landed set.
|
|
5467
|
-
// (test/editor-client.test.js re-runs that sweep and pins all three numbers.)
|
|
5468
|
-
//
|
|
5469
|
-
// RE-WORDED with the narrowing, and the wording is the point: Task 4's
|
|
5470
|
-
// 「搬移後子項目會失去上層項目,暫時無法搬移到這裡」 said "temporarily", which
|
|
5471
|
-
// was true while the capability was missing and is a lie now that it is not.
|
|
5472
|
-
// What is left is a DESTINATION objection with the same remedy
|
|
5473
|
-
// BLOCK_MOVE_DEST_SEAM_MESSAGE has — aim somewhere else — so it says which
|
|
5474
|
-
// end of the gesture is at fault. Still not a substring of any other refusal
|
|
5475
|
-
// in this file, which is what lets a test assert WHICH one fired.
|
|
5476
|
-
const BLOCK_MOVE_ORPHAN_MESSAGE = '落點的子項目會失去上層項目,無法搬移到這裡';
|
|
5477
5466
|
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
5478
5467
|
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
5479
5468
|
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
5480
5469
|
// toggleGutterMenu(), where the menu-hiding call site can share them. The
|
|
5481
|
-
//
|
|
5470
|
+
// constants above are shape-level and belong to every batch operation.
|
|
5482
5471
|
|
|
5483
5472
|
function blockElById(id) {
|
|
5484
5473
|
return document.querySelector('.ed-block[data-block-id="' + id + '"]');
|
|
@@ -5728,9 +5717,11 @@
|
|
|
5728
5717
|
// null (dropped gesture, no source
|
|
5729
5718
|
// line, empty/gapped/no-element
|
|
5730
5719
|
// batch)
|
|
5731
|
-
// 2.
|
|
5732
|
-
//
|
|
5733
|
-
//
|
|
5720
|
+
// 2. (v3.11: retired — a mixed / withheld / two-list span routes to
|
|
5721
|
+
// convertSpanAcrossLists(), which has its own exits below)
|
|
5722
|
+
// 3. the all-withheld refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
|
|
5723
|
+
// 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE; now
|
|
5724
|
+
// unreachable, kept as a guard)
|
|
5734
5725
|
// 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
|
|
5735
5726
|
// 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
|
|
5736
5727
|
// — six, not three, plus `result.op === null` (a commit that wrote nothing)
|
|
@@ -5776,41 +5767,20 @@
|
|
|
5776
5767
|
const els = operands.els;
|
|
5777
5768
|
const recs = operands.recs;
|
|
5778
5769
|
const shape = spanListKinds(els);
|
|
5779
|
-
|
|
5780
|
-
//
|
|
5781
|
-
//
|
|
5782
|
-
//
|
|
5783
|
-
//
|
|
5784
|
-
//
|
|
5785
|
-
//
|
|
5786
|
-
//
|
|
5787
|
-
//
|
|
5788
|
-
//
|
|
5789
|
-
//
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
// byte-identical.
|
|
5794
|
-
//
|
|
5795
|
-
// The banner names the type that is ACTUALLY in the span, not one of the
|
|
5796
|
-
// four shape-level messages above: BATCH_MIXED names list items vs other
|
|
5797
|
-
// blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
|
|
5798
|
-
// names non-contiguity — either would name something the user's selection
|
|
5799
|
-
// has not done. So would telling a user with an hr in the selection that it
|
|
5800
|
-
// contains a table.
|
|
5801
|
-
//
|
|
5802
|
-
// SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
|
|
5803
|
-
// holding any of these types are measured correct and have no equivalent
|
|
5804
|
-
// objection — they do not need a target that can carry cells, or a marker
|
|
5805
|
-
// to strip — so the predicate deliberately does NOT reach
|
|
5806
|
-
// resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
|
|
5807
|
-
// those five cells as APPLYING so a widening shows up as a failure too.
|
|
5808
|
-
// Placed BELOW the mixed gate so no span that already refuses changes which
|
|
5809
|
-
// banner it gets.
|
|
5810
|
-
const withheld = shape.kinds.filter(convertWithheldFor)[0];
|
|
5811
|
-
if (withheld) {
|
|
5812
|
-
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
|
|
5813
|
-
return;
|
|
5770
|
+
// §3.7 / §7 withhold 轉換成 from a table, an hr and a raw html block —
|
|
5771
|
+
// toggleGutterMenu() hides the item outright on a single one of them.
|
|
5772
|
+
// v3.11: a SPAN holding one converts its other members and keeps the
|
|
5773
|
+
// withheld ones byte for byte, with a notice naming what was skipped
|
|
5774
|
+
// (Notion skips what it cannot turn); a span mixing list items with other
|
|
5775
|
+
// blocks, or taking items of two lists, converts as well. All three used
|
|
5776
|
+
// to refuse (BATCH_MIXED_MESSAGE / BATCH_MULTIRUN_MESSAGE /
|
|
5777
|
+
// BATCH_CONVERT_WITHHELD_MESSAGES). Before the withheld refusal existed a
|
|
5778
|
+
// table span wrote '> | A | B |' … — convertSpanAcrossLists() never
|
|
5779
|
+
// strips a withheld member, which is why it is the route for all of them.
|
|
5780
|
+
// A span of ONLY withheld blocks still refuses: nothing in it converts.
|
|
5781
|
+
if ((shape.anyLi && !shape.allLi) || shape.kinds.some(convertWithheldFor) ||
|
|
5782
|
+
(shape.allLi && !batchRunOf(els))) {
|
|
5783
|
+
return await convertSpanAcrossLists(els, recs, shape.kinds, target);
|
|
5814
5784
|
}
|
|
5815
5785
|
|
|
5816
5786
|
// §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
|
|
@@ -5819,25 +5789,20 @@
|
|
|
5819
5789
|
// is exactly what listRunOf() returns (the outermost run PLUS every
|
|
5820
5790
|
// descendant of its members) — see deleteListItemsViaGutter()'s own note.
|
|
5821
5791
|
//
|
|
5822
|
-
//
|
|
5823
|
-
//
|
|
5824
|
-
//
|
|
5825
|
-
// convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
|
|
5826
|
-
// would get if stripMarker() saw it first (that function refuses a
|
|
5827
|
-
// multi-line li too, for its own, narrower reason). The runtime scenario
|
|
5828
|
-
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
|
|
5829
|
-
// is what notices if this order is ever flipped.
|
|
5792
|
+
// It sits AHEAD of the per-target routing below, so a run the gate
|
|
5793
|
+
// refuses (an unsupported shape) reports §4.1's
|
|
5794
|
+
// 「此清單含不支援的格式,無法調整結構」 before anything else is tried.
|
|
5830
5795
|
//
|
|
5831
|
-
//
|
|
5832
|
-
//
|
|
5833
|
-
//
|
|
5834
|
-
//
|
|
5835
|
-
//
|
|
5796
|
+
// v3.11: { replaysTargets: true } — a multi-line li converts (a li→li
|
|
5797
|
+
// change replays its bytes with the marker's column delta; li → non-list
|
|
5798
|
+
// reads them from `lines` through stripMarker()), so §4.1 修訂 2's
|
|
5799
|
+
// TARGET refusal no longer applies. S3 Task 6 passes the WHOLE operand set
|
|
5800
|
+
// — every member is being rewritten, so every member clears the gate.
|
|
5836
5801
|
let liRun = null;
|
|
5837
5802
|
if (shape.allLi) {
|
|
5838
5803
|
liRun = batchRunOf(els);
|
|
5839
5804
|
if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
5840
|
-
if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
|
|
5805
|
+
if (!listRunSupportsStructuralEdit(liRun, els, { replaysTargets: true })) { refuseStructuralListEdit(); return; }
|
|
5841
5806
|
}
|
|
5842
5807
|
|
|
5843
5808
|
// S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
|
|
@@ -6013,7 +5978,12 @@
|
|
|
6013
5978
|
const els = operands.els;
|
|
6014
5979
|
const recs = operands.recs;
|
|
6015
5980
|
const shape = spanListKinds(els);
|
|
6016
|
-
|
|
5981
|
+
// v3.11: a span mixing list items with other blocks, or taking items of
|
|
5982
|
+
// two lists, duplicates too (it used to refuse with BATCH_MIXED_MESSAGE /
|
|
5983
|
+
// BATCH_MULTIRUN_MESSAGE).
|
|
5984
|
+
if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
|
|
5985
|
+
return (await duplicateSpanAcrossLists(els, recs)) ? recs[0].startLine : undefined;
|
|
5986
|
+
}
|
|
6017
5987
|
|
|
6018
5988
|
if (shape.allLi) {
|
|
6019
5989
|
// recs[0].startLine is unchanged by this commit — duplicateListItems()'s
|
|
@@ -6068,6 +6038,60 @@
|
|
|
6068
6038
|
// of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
|
|
6069
6039
|
// a refusal (nothing was duplicated, the block is still where it was) apart
|
|
6070
6040
|
// from a completed duplicate.
|
|
6041
|
+
// v3.11: 建立副本 over a span that mixes list items with other blocks, or
|
|
6042
|
+
// takes items of two lists. The copy is the span's own source lines,
|
|
6043
|
+
// byte for byte, inserted after the span (after the last member's subtree
|
|
6044
|
+
// when that member is a list item, like duplicateListItems()), in ONE
|
|
6045
|
+
// commit. Where the copy meets a neighbour it joins tight when both sides
|
|
6046
|
+
// are list items — the copied items rejoin the list, as in Notion — and is
|
|
6047
|
+
// kept apart by a blank line otherwise.
|
|
6048
|
+
async function duplicateSpanAcrossLists(els, recs) {
|
|
6049
|
+
const first = recs[0];
|
|
6050
|
+
const lastEl = els[els.length - 1];
|
|
6051
|
+
let endRec = recs[recs.length - 1];
|
|
6052
|
+
if (lastEl.getAttribute('data-block-type') === 'li') {
|
|
6053
|
+
const sub = subtreeBlocksAfter(lastEl, Number(lastEl.getAttribute('data-indent')) || 0);
|
|
6054
|
+
const subRec = sub.length ? blockRecOf(sub[sub.length - 1]) : null;
|
|
6055
|
+
if (subRec) endRec = subRec;
|
|
6056
|
+
}
|
|
6057
|
+
const at = endRec.endLine;
|
|
6058
|
+
const copy = lines.slice(first.startLine - 1, recs[recs.length - 1].endLine);
|
|
6059
|
+
const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
|
|
6060
|
+
const endEl = blockElById(endRec.id);
|
|
6061
|
+
const nextRec = blocks[blocks.indexOf(endRec) + 1] || null;
|
|
6062
|
+
const nextEl = nextRec ? blockElById(nextRec.id) : null;
|
|
6063
|
+
const nextLine = at < lines.length ? String(lines[at]) : null;
|
|
6064
|
+
const out = [];
|
|
6065
|
+
const tightBefore = isLi(endEl) && isLi(els[0]);
|
|
6066
|
+
if (!tightBefore) {
|
|
6067
|
+
// A blank line in front: an indented first line would read as code, and
|
|
6068
|
+
// an ordered item restarts its own count.
|
|
6069
|
+
if (/^( {4}|\t)/.test(copy[0])) { refuseStructuralListEdit(); return false; }
|
|
6070
|
+
if (isLi(els[0])) copy[0] = copy[0].replace(/^(\s*)\d+([.)])/, '$11$2');
|
|
6071
|
+
out.push('');
|
|
6072
|
+
}
|
|
6073
|
+
out.push(...copy);
|
|
6074
|
+
const tightAfter = isLi(lastEl) && isLi(nextEl) && nextLine !== null && nextLine.trim() !== '';
|
|
6075
|
+
if (!tightAfter && nextLine !== null && nextLine.trim() !== '') out.push('');
|
|
6076
|
+
const result = commitRangeEdit({ lines, blocks, stack }, at, at,
|
|
6077
|
+
[lines[at - 1]].concat(out).join('\n'));
|
|
6078
|
+
if (result.op === null) return false;
|
|
6079
|
+
endBurstWithoutResolve();
|
|
6080
|
+
const prevLines = lines;
|
|
6081
|
+
lines = result.lines;
|
|
6082
|
+
declareCollapse({ startLine: first.startLine, endLine: recs[recs.length - 1].endLine });
|
|
6083
|
+
const editRange = result.op
|
|
6084
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6085
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6086
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6087
|
+
: undefined;
|
|
6088
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
6089
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6090
|
+
return false;
|
|
6091
|
+
}
|
|
6092
|
+
return true;
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6071
6095
|
async function duplicateListItems(liEls, recs) {
|
|
6072
6096
|
// Every list batch is one run's problem — see batchRunOf().
|
|
6073
6097
|
const run = batchRunOf(liEls);
|
|
@@ -6079,7 +6103,7 @@
|
|
|
6079
6103
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
6080
6104
|
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
6081
6105
|
// member is a target.
|
|
6082
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
6106
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
|
|
6083
6107
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
6084
6108
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
6085
6109
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
@@ -6262,15 +6286,13 @@
|
|
|
6262
6286
|
const els = operands.els;
|
|
6263
6287
|
const recs = operands.recs;
|
|
6264
6288
|
const shape = spanListKinds(els);
|
|
6265
|
-
// A mixed span
|
|
6266
|
-
//
|
|
6267
|
-
//
|
|
6268
|
-
//
|
|
6269
|
-
//
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
refuseStructuralListEdit(BATCH_PARTIAL_LIST_DELETE_MESSAGE); return;
|
|
6273
|
-
}
|
|
6289
|
+
// A mixed span that covers every list it touches WHOLE is one contiguous
|
|
6290
|
+
// line range like any non-list span -- the commitRangeRemoval() branch
|
|
6291
|
+
// below, one undo op. v3.11: a list covered only in PART (or items of two
|
|
6292
|
+
// lists) no longer refuses — deleteSpanAcrossLists(), routed below once
|
|
6293
|
+
// the caret landing has been read.
|
|
6294
|
+
const acrossLists = (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) ||
|
|
6295
|
+
(shape.allLi && !batchRunOf(els));
|
|
6274
6296
|
// Fix round 1, item 2: the block immediately ABOVE the span, read out of
|
|
6275
6297
|
// the PRE-commit `blocks` and carried to the wrapper. It cannot be
|
|
6276
6298
|
// recovered afterwards from the hole's coordinate — see
|
|
@@ -6302,6 +6324,10 @@
|
|
|
6302
6324
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
6303
6325
|
// below corrupts a list three separate ways — see
|
|
6304
6326
|
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
6327
|
+
if (acrossLists) {
|
|
6328
|
+
return (await deleteSpanAcrossLists(els, recs))
|
|
6329
|
+
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
6330
|
+
}
|
|
6305
6331
|
if (shape.allLi) {
|
|
6306
6332
|
return (await deleteListItemsViaGutter(els))
|
|
6307
6333
|
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
@@ -6371,6 +6397,94 @@
|
|
|
6371
6397
|
// of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
|
|
6372
6398
|
// a refusal (nothing was removed, the block is still where it was) apart from
|
|
6373
6399
|
// a completed delete before it goes looking for a neighbour.
|
|
6400
|
+
// v3.11: 刪除 over a span that cuts into one or more lists. ONE commit (one
|
|
6401
|
+
// undo step) over the region from the first touched list's first line to
|
|
6402
|
+
// the last touched list's last line: every touched list keeps its unselected
|
|
6403
|
+
// items (re-serialized the way deleteListItemsViaGutter() does it, bystander
|
|
6404
|
+
// bytes replayed), every other selected block is dropped. Two lists whose
|
|
6405
|
+
// survivors end up adjacent join tight — Notion's behaviour; the serializer
|
|
6406
|
+
// renumbers every ordered piece from 1, so the join is always well-formed.
|
|
6407
|
+
async function deleteSpanAcrossLists(els, recs) {
|
|
6408
|
+
const groups = [];
|
|
6409
|
+
for (let i = 0; i < els.length; i++) {
|
|
6410
|
+
const el = els[i];
|
|
6411
|
+
if (el.getAttribute('data-block-type') !== 'li') continue;
|
|
6412
|
+
let g = groups.find((x) => x.run.indexOf(el) !== -1);
|
|
6413
|
+
if (!g) {
|
|
6414
|
+
const run = listRunOf(el);
|
|
6415
|
+
if (!run.length) { refuseStructuralListEdit(); return false; }
|
|
6416
|
+
g = { run: run, targets: [] };
|
|
6417
|
+
groups.push(g);
|
|
6418
|
+
}
|
|
6419
|
+
g.targets.push(el);
|
|
6420
|
+
}
|
|
6421
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6422
|
+
const g = groups[i];
|
|
6423
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
|
|
6424
|
+
refuseStructuralListEdit(); return false;
|
|
6425
|
+
}
|
|
6426
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
6427
|
+
if (!g.range) return false;
|
|
6428
|
+
}
|
|
6429
|
+
let lo = recs[0].startLine;
|
|
6430
|
+
let hi = recs[recs.length - 1].endLine;
|
|
6431
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
6432
|
+
// Everything inside [lo, hi] must be either selected or one of a touched
|
|
6433
|
+
// list's own lines — anything else would be silently lost, so refuse.
|
|
6434
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
6435
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
6436
|
+
const b = blocks[i];
|
|
6437
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
6438
|
+
const k = recs.indexOf(b);
|
|
6439
|
+
if (k !== -1 && els[k].getAttribute('data-block-type') !== 'li' && inRun(b)) {
|
|
6440
|
+
refuseStructuralListEdit(); return false;
|
|
6441
|
+
}
|
|
6442
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return false; }
|
|
6443
|
+
}
|
|
6444
|
+
groups.forEach((g) => {
|
|
6445
|
+
g.survivors = g.run.filter((el) => g.targets.indexOf(el) === -1);
|
|
6446
|
+
});
|
|
6447
|
+
mutateListRun(() => {
|
|
6448
|
+
groups.forEach((g) => {
|
|
6449
|
+
applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { removed: true });
|
|
6450
|
+
g.targets.forEach((el) => removeListItem(el));
|
|
6451
|
+
});
|
|
6452
|
+
});
|
|
6453
|
+
const carry = bystanderCarryOver([].concat(...groups.map((g) => g.survivors)));
|
|
6454
|
+
const pieces = groups
|
|
6455
|
+
.map((g) => g.survivors.length ? serializeListEls(g.survivors, { carryOver: carry }).md : '')
|
|
6456
|
+
.filter((piece) => piece !== '');
|
|
6457
|
+
let md = pieces.join('\n');
|
|
6458
|
+
if (md !== '') {
|
|
6459
|
+
// A removed block at an edge of the region: keep the new text apart
|
|
6460
|
+
// from a neighbour that is not a blank line.
|
|
6461
|
+
const head = groups[0];
|
|
6462
|
+
const tail = groups[groups.length - 1];
|
|
6463
|
+
const startsInRun = head && head.range.startLine === lo && head.survivors.length;
|
|
6464
|
+
const endsInRun = tail && tail.range.endLine === hi && tail.survivors.length;
|
|
6465
|
+
if (!startsInRun && lo > 1 && String(lines[lo - 2]).trim() !== '') md = '\n' + md;
|
|
6466
|
+
if (!endsInRun && hi < lines.length && String(lines[hi]).trim() !== '') md = md + '\n';
|
|
6467
|
+
}
|
|
6468
|
+
declareCollapse(null);
|
|
6469
|
+
const result = md === ''
|
|
6470
|
+
? commitRangeRemoval({ lines, blocks, stack }, lo, hi)
|
|
6471
|
+
: commitRangeEdit({ lines, blocks, stack }, lo, hi, md);
|
|
6472
|
+
endBurstWithoutResolve();
|
|
6473
|
+
const prevLines = lines;
|
|
6474
|
+
if (result.op !== null) lines = result.lines;
|
|
6475
|
+
const editRange = result.op
|
|
6476
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6477
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6478
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6479
|
+
: undefined;
|
|
6480
|
+
const ok = await safeRerenderAll(editRange);
|
|
6481
|
+
if (!ok) {
|
|
6482
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6483
|
+
return false;
|
|
6484
|
+
}
|
|
6485
|
+
return true;
|
|
6486
|
+
}
|
|
6487
|
+
|
|
6374
6488
|
async function deleteListItemsViaGutter(liEls) {
|
|
6375
6489
|
// Every list batch is one run's problem — see batchRunOf().
|
|
6376
6490
|
const run = batchRunOf(liEls);
|
|
@@ -6381,7 +6495,7 @@
|
|
|
6381
6495
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
6382
6496
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
6383
6497
|
// target refuses per §4.1 — and in a batch every member is a target.
|
|
6384
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
6498
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { replaysTargets: true })) { refuseStructuralListEdit(); return false; }
|
|
6385
6499
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
6386
6500
|
if (!range) return false;
|
|
6387
6501
|
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
@@ -6557,6 +6671,162 @@
|
|
|
6557
6671
|
};
|
|
6558
6672
|
}
|
|
6559
6673
|
|
|
6674
|
+
// v3.11: 轉換成 over a span the single-kind paths cannot take — list items
|
|
6675
|
+
// mixed with other blocks, items of two lists, or a table / hr / html block
|
|
6676
|
+
// among the members (those three have no content to re-host, so they are
|
|
6677
|
+
// kept as they are and a notice says so — Notion skips what it cannot
|
|
6678
|
+
// turn). ONE commit over the region from the first touched list's first
|
|
6679
|
+
// line to the last touched list's last line: unselected items before the
|
|
6680
|
+
// span (first list) and after it (last list) are re-serialized the way
|
|
6681
|
+
// convertListItemsAway() does it, orphaned children clamped; every selected
|
|
6682
|
+
// block is stripMarker() → emitAs()'d from its own source lines. A list
|
|
6683
|
+
// target turns the members into one list (nested items keep their nesting,
|
|
6684
|
+
// re-based on the first member); pieces that are list items on both sides
|
|
6685
|
+
// of a seam join tight.
|
|
6686
|
+
async function convertSpanAcrossLists(els, recs, kinds, target) {
|
|
6687
|
+
const toList = convertMd.targetIsList(target);
|
|
6688
|
+
const isLi = (el) => el.getAttribute('data-block-type') === 'li';
|
|
6689
|
+
const groups = [];
|
|
6690
|
+
for (let i = 0; i < els.length; i++) {
|
|
6691
|
+
if (!isLi(els[i])) continue;
|
|
6692
|
+
let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
|
|
6693
|
+
if (!g) {
|
|
6694
|
+
const run = listRunOf(els[i]);
|
|
6695
|
+
if (!run.length) { refuseStructuralListEdit(); return; }
|
|
6696
|
+
g = { run: run, targets: [] };
|
|
6697
|
+
groups.push(g);
|
|
6698
|
+
}
|
|
6699
|
+
g.targets.push(els[i]);
|
|
6700
|
+
}
|
|
6701
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6702
|
+
const g = groups[i];
|
|
6703
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { replaysTargets: true })) {
|
|
6704
|
+
refuseStructuralListEdit(); return;
|
|
6705
|
+
}
|
|
6706
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
6707
|
+
if (!g.range) return;
|
|
6708
|
+
}
|
|
6709
|
+
let lo = recs[0].startLine;
|
|
6710
|
+
let hi = recs[recs.length - 1].endLine;
|
|
6711
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
6712
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
6713
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
6714
|
+
const b = blocks[i];
|
|
6715
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
6716
|
+
const k = recs.indexOf(b);
|
|
6717
|
+
if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
|
|
6718
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
|
|
6719
|
+
}
|
|
6720
|
+
// Unselected items may only sit BEFORE the span (in its first list) or
|
|
6721
|
+
// AFTER it (in its last list) — the span is one contiguous line range.
|
|
6722
|
+
const firstEl = els[0];
|
|
6723
|
+
const lastEl = els[els.length - 1];
|
|
6724
|
+
const allEls = allBlockEls();
|
|
6725
|
+
const before = [];
|
|
6726
|
+
const after = [];
|
|
6727
|
+
for (let i = 0; i < groups.length; i++) {
|
|
6728
|
+
const g = groups[i];
|
|
6729
|
+
for (let j = 0; j < g.run.length; j++) {
|
|
6730
|
+
const el = g.run[j];
|
|
6731
|
+
if (g.targets.indexOf(el) !== -1) continue;
|
|
6732
|
+
if (allEls.indexOf(el) < allEls.indexOf(firstEl)) before.push(el);
|
|
6733
|
+
else if (allEls.indexOf(el) > allEls.indexOf(lastEl)) after.push(el);
|
|
6734
|
+
else { refuseStructuralListEdit(); return; }
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6737
|
+
|
|
6738
|
+
// The members, converted. A run of consecutive convertible members is one
|
|
6739
|
+
// chunk; a withheld member is its own chunk, kept byte for byte.
|
|
6740
|
+
const skipped = [];
|
|
6741
|
+
const chunks = [];
|
|
6742
|
+
let cur = null;
|
|
6743
|
+
let level0 = null;
|
|
6744
|
+
const ordinals = [];
|
|
6745
|
+
for (let i = 0; i < els.length; i++) {
|
|
6746
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
6747
|
+
if (convertWithheldFor(kinds[i])) {
|
|
6748
|
+
if (skipped.indexOf(kinds[i]) === -1) skipped.push(kinds[i]);
|
|
6749
|
+
chunks.push({ list: false, text: src.join('\n') });
|
|
6750
|
+
cur = null;
|
|
6751
|
+
ordinals.length = 0;
|
|
6752
|
+
continue;
|
|
6753
|
+
}
|
|
6754
|
+
const stripped = convertMd.stripMarker(src, kinds[i]);
|
|
6755
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
6756
|
+
if (!toList) {
|
|
6757
|
+
chunks.push({ list: false, text: convertMd.emitAs(stripped.content, target, {}).join('\n') });
|
|
6758
|
+
continue;
|
|
6759
|
+
}
|
|
6760
|
+
// Nesting, re-based so the first member sits at level 0 and no item
|
|
6761
|
+
// is ever more than one level deeper than the one above it.
|
|
6762
|
+
let level = isLi(els[i]) ? (Number(els[i].getAttribute('data-indent')) || 0) : 0;
|
|
6763
|
+
if (level0 === null) level0 = level;
|
|
6764
|
+
level = Math.max(0, level - level0);
|
|
6765
|
+
if (!cur) { cur = { list: true, lines: [], prevLevel: -1 }; chunks.push(cur); ordinals.length = 0; }
|
|
6766
|
+
level = Math.min(level, cur.prevLevel + 1);
|
|
6767
|
+
cur.prevLevel = level;
|
|
6768
|
+
ordinals.length = level + 1;
|
|
6769
|
+
ordinals[level] = (ordinals[level] || 0) + 1;
|
|
6770
|
+
const width = target === 'ol' ? 3 : 2;
|
|
6771
|
+
const checked = /^\s*(?:[-*+]|\d+[.)])\s+\[[xX]\]/.test(src[0]);
|
|
6772
|
+
cur.lines.push(...convertMd.emitAs(stripped.content, target,
|
|
6773
|
+
{ ordinal: ordinals[level], checked: checked, indentPrefix: ' '.repeat(level * width) }));
|
|
6774
|
+
}
|
|
6775
|
+
const middle = chunks.map((c) => (c.list ? { list: true, text: c.lines.join('\n') } : c));
|
|
6776
|
+
if (skipped.length === middle.length) {
|
|
6777
|
+
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[skipped[0]]); return;
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
// Orphans after the span lose an ancestor that is no longer an item.
|
|
6781
|
+
if (!toList && after.length && groups.length) {
|
|
6782
|
+
const g = groups[groups.length - 1];
|
|
6783
|
+
mutateListRun(() => {
|
|
6784
|
+
applyIndentClamp(g.run, g.targets, spanMinIndent(g.targets), { operatedBecomes: { type: convertedBlockType(target) } });
|
|
6785
|
+
});
|
|
6786
|
+
}
|
|
6787
|
+
const carry = bystanderCarryOver(before.concat(after));
|
|
6788
|
+
const pieces = [];
|
|
6789
|
+
if (before.length) pieces.push({ list: true, text: serializeListEls(before, { carryOver: carry }).md });
|
|
6790
|
+
const midFrom = pieces.length;
|
|
6791
|
+
pieces.push(...middle);
|
|
6792
|
+
const midTo = pieces.length;
|
|
6793
|
+
if (after.length) pieces.push({ list: true, text: serializeListEls(after, { carryOver: carry }).md });
|
|
6794
|
+
const out = [];
|
|
6795
|
+
let midStart = null;
|
|
6796
|
+
let midEnd = null;
|
|
6797
|
+
pieces.forEach((p, i) => {
|
|
6798
|
+
if (i > 0) {
|
|
6799
|
+
if (!(p.list && pieces[i - 1].list)) out.push('');
|
|
6800
|
+
}
|
|
6801
|
+
if (i === midFrom) midStart = out.length;
|
|
6802
|
+
out.push(...p.text.split('\n'));
|
|
6803
|
+
if (i === midTo - 1) midEnd = out.length - 1;
|
|
6804
|
+
});
|
|
6805
|
+
let lead = 0;
|
|
6806
|
+
if (!before.length && lo > 1 && String(lines[lo - 2]).trim() !== '') { out.unshift(''); lead = 1; }
|
|
6807
|
+
if (!after.length && hi < lines.length && String(lines[hi]).trim() !== '') out.push('');
|
|
6808
|
+
|
|
6809
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
|
|
6810
|
+
if (result.op === null) return;
|
|
6811
|
+
endBurstWithoutResolve();
|
|
6812
|
+
const prevLines = lines;
|
|
6813
|
+
lines = result.lines;
|
|
6814
|
+
declareCollapse({ startLine: lo + lead + midStart, endLine: lo + lead + midEnd });
|
|
6815
|
+
const editRange = result.op
|
|
6816
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6817
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6818
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6819
|
+
: undefined;
|
|
6820
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
6821
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
6822
|
+
return;
|
|
6823
|
+
}
|
|
6824
|
+
if (skipped.length) {
|
|
6825
|
+
showNotice('已略過無法轉換的區塊:' + skipped.map((k) => CONVERT_SKIP_LABELS[k]).join('、'));
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6828
|
+
const CONVERT_SKIP_LABELS = { table: '表格', hr: '分隔線', html: 'HTML 區塊' };
|
|
6829
|
+
|
|
6560
6830
|
async function convertListItemsType(liEls, run, recs, target) {
|
|
6561
6831
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
6562
6832
|
if (!range) return;
|
|
@@ -6738,13 +7008,13 @@
|
|
|
6738
7008
|
// back through escapeText() — see convertListItemsType()'s note.
|
|
6739
7009
|
const carry = bystanderCarryOver(before.concat(after));
|
|
6740
7010
|
const pieces = [];
|
|
6741
|
-
if (before.length) pieces.push(
|
|
7011
|
+
if (before.length) pieces.push(serializeListEls(before, { carryOver: carry }).md);
|
|
6742
7012
|
// Which piece the converted blocks are — the collapse range below counts
|
|
6743
7013
|
// emitted LINES up to it, and "0 or 1" is only true while `before` is the
|
|
6744
7014
|
// one optional piece ahead of it.
|
|
6745
7015
|
const convertedPieceIdx = pieces.length;
|
|
6746
7016
|
pieces.push(convertedMd);
|
|
6747
|
-
if (after.length) pieces.push(
|
|
7017
|
+
if (after.length) pieces.push(serializeListEls(after, { carryOver: carry }).md);
|
|
6748
7018
|
let md = pieces.join('\n\n');
|
|
6749
7019
|
|
|
6750
7020
|
// §4.3 rule 1 at the RUN's own edges. Inside the range the '\n\n' joins
|
|
@@ -7146,7 +7416,7 @@
|
|
|
7146
7416
|
// source range.
|
|
7147
7417
|
const carry = bystanderCarryOver(runEls, editedLiEl);
|
|
7148
7418
|
const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
|
|
7149
|
-
|
|
7419
|
+
serializeListEls(runEls, { carryOver: carry });
|
|
7150
7420
|
// Refuse if the EDITED li itself has unsupported inline content.
|
|
7151
7421
|
// Show banner + teardown + rerenderAll (file is untouched, burst never
|
|
7152
7422
|
// wrote to `lines`) + return false — this path does NOT fall back to
|
|
@@ -7172,7 +7442,15 @@
|
|
|
7172
7442
|
const range = runRangeOfBlocks({ lines, blocks, stack }, runEls);
|
|
7173
7443
|
if (!range) { endBurstWithoutResolve(); return true; }
|
|
7174
7444
|
let commitMd, commitStart, commitEnd;
|
|
7175
|
-
|
|
7445
|
+
// v3.11: a LOOSE run used to reach this branch through its 'P' items,
|
|
7446
|
+
// and a loose run holding a same-line nest ('- - b', an item with no
|
|
7447
|
+
// line of its own) still has to: re-serializing the whole run re-emits
|
|
7448
|
+
// that nest in its canonical multi-line form, which inside a loose list
|
|
7449
|
+
// changes the rendered nesting (measured: '- - - b' came back with an
|
|
7450
|
+
// escaped empty item). Committing only the edited item's lines keeps it.
|
|
7451
|
+
const looseWithNest = runEls.some((el) => blockOwnsNoLine(el)) &&
|
|
7452
|
+
runEls.some((el) => el.getAttribute('data-list-loose') === '1');
|
|
7453
|
+
if (unsupported.length > 0 || looseWithNest) {
|
|
7176
7454
|
// The run contains SOME unsupported content — commit only the edited
|
|
7177
7455
|
// li's own line range so nothing else in the run is round-tripped
|
|
7178
7456
|
// through the tight (blank-line-collapsed) runMd.
|
|
@@ -8006,7 +8284,7 @@
|
|
|
8006
8284
|
async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
|
|
8007
8285
|
const span = runEls || [];
|
|
8008
8286
|
const presetRange = opts && opts.presetRange;
|
|
8009
|
-
const { md } =
|
|
8287
|
+
const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
|
|
8010
8288
|
// The run's line range is read back off its own li blocks' ids — which
|
|
8011
8289
|
// requires at least one li to still BE there. A caller whose mutation
|
|
8012
8290
|
// removed the run's last item therefore captures the range BEFORE mutating
|
|
@@ -8073,7 +8351,7 @@
|
|
|
8073
8351
|
// can emit a different number of lines than a re-serialized one would (a
|
|
8074
8352
|
// blank continuation is dropped on the re-serialize path but replayed
|
|
8075
8353
|
// verbatim here), and this answer is an index INTO those lines.
|
|
8076
|
-
const { lineMeta } =
|
|
8354
|
+
const { lineMeta } = serializeListEls(runEls, { carryOver: carryOver || null });
|
|
8077
8355
|
// A provisional block (split's new item) has no id yet, so it cannot be
|
|
8078
8356
|
// found by one — fall back to counting the lines emitted before it.
|
|
8079
8357
|
if (targetId === null) {
|
|
@@ -8082,7 +8360,16 @@
|
|
|
8082
8360
|
const before = runEls.slice(0, at).map((el) => el.getAttribute('data-block-id'));
|
|
8083
8361
|
let n = 0;
|
|
8084
8362
|
lineMeta.forEach((m) => { if (before.indexOf(m.blockId) !== -1) n++; });
|
|
8085
|
-
|
|
8363
|
+
// v3.11: a loose list's blank lines (lineMeta `blank`) sit between the
|
|
8364
|
+
// items counted above and this one; they are lines too.
|
|
8365
|
+
let k = 0;
|
|
8366
|
+
let seen = 0;
|
|
8367
|
+
while (k < lineMeta.length && seen < n) {
|
|
8368
|
+
if (before.indexOf(lineMeta[k].blockId) !== -1) seen++;
|
|
8369
|
+
k++;
|
|
8370
|
+
}
|
|
8371
|
+
while (k < lineMeta.length && lineMeta[k].blank) k++;
|
|
8372
|
+
return range.startLine + k;
|
|
8086
8373
|
}
|
|
8087
8374
|
const idx = lineMeta.findIndex((m) => m.blockId === targetId);
|
|
8088
8375
|
if (idx === -1) return null;
|
|
@@ -8169,13 +8456,21 @@
|
|
|
8169
8456
|
// excluding a burst whose surface has actually been edited.
|
|
8170
8457
|
function listRunSupportsStructuralEdit(runEls, targetEl, opts) {
|
|
8171
8458
|
if (!runEls || !runEls.length) return false;
|
|
8172
|
-
const res =
|
|
8459
|
+
const res = serializeListEls(runEls);
|
|
8173
8460
|
const multi = res.multiLineBlockIds || [];
|
|
8174
8461
|
// Anything OTHER than MULTILINE still refuses run-wide, unchanged.
|
|
8175
8462
|
for (let i = 0; i < res.unsupported.length; i++) {
|
|
8176
8463
|
if (res.unsupported[i] !== 'MULTILINE') return false;
|
|
8177
8464
|
}
|
|
8178
8465
|
if (opts && opts.columnOnly) return true;
|
|
8466
|
+
// v3.11: `replaysTargets` — the operation never re-serializes a target's
|
|
8467
|
+
// CONTENT: it removes the target (delete), replays its source bytes
|
|
8468
|
+
// (duplicate's copy carries the original's id into bystanderCarryOver();
|
|
8469
|
+
// a li→li type change replays them with the marker's column delta), or
|
|
8470
|
+
// reads them straight from `lines` (li → non-list, convert-md.js's
|
|
8471
|
+
// stripMarker()). A multi-line target is then no different from a
|
|
8472
|
+
// multi-line bystander, which has always been fine.
|
|
8473
|
+
if (opts && opts.replaysTargets) return true;
|
|
8179
8474
|
// S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
|
|
8180
8475
|
// one run and every one of them has to clear §4.1's multi-line gate, not
|
|
8181
8476
|
// just the one the ⠿ was pressed on. An empty array is the same question as
|
|
@@ -9122,6 +9417,114 @@
|
|
|
9122
9417
|
// what makes 「段落 no-op」 a real no-op instead of a re-serialization that
|
|
9123
9418
|
// could move bytes nobody asked it to, and it is why this is ONE
|
|
9124
9419
|
// commitRangeEdit and therefore one undo op.
|
|
9420
|
+
// v3.11: Tab / Shift+Tab over a span mixing list items with other blocks,
|
|
9421
|
+
// or taking items of two lists. Each kind follows its own rule — items
|
|
9422
|
+
// indent (indentListItemsBySelection()'s headroom and clamp, per list),
|
|
9423
|
+
// headings change depth (changeHeadingDepthsInSpan()), paragraphs stay —
|
|
9424
|
+
// in ONE commit over the region the touched lists span, which is what the
|
|
9425
|
+
// old refusal said could not be done (two commits, two undo ops). Lists
|
|
9426
|
+
// that are adjacent blocks (a marker change between them, no gap) move as
|
|
9427
|
+
// one group, so an item of the second can land under an item of the first.
|
|
9428
|
+
async function tabSpanAcrossLists(els, recs, dir) {
|
|
9429
|
+
const isLi = (el) => el.getAttribute('data-block-type') === 'li';
|
|
9430
|
+
const all = allBlockEls();
|
|
9431
|
+
const groups = [];
|
|
9432
|
+
for (let i = 0; i < els.length; i++) {
|
|
9433
|
+
if (!isLi(els[i])) continue;
|
|
9434
|
+
let g = groups.find((x) => x.run.indexOf(els[i]) !== -1);
|
|
9435
|
+
if (!g) {
|
|
9436
|
+
const run = listRunOf(els[i]);
|
|
9437
|
+
if (!run.length) { refuseStructuralListEdit(); return; }
|
|
9438
|
+
const prev = groups[groups.length - 1];
|
|
9439
|
+
if (prev && all.indexOf(run[0]) === all.indexOf(prev.run[prev.run.length - 1]) + 1) {
|
|
9440
|
+
prev.run = prev.run.concat(run);
|
|
9441
|
+
g = prev;
|
|
9442
|
+
} else {
|
|
9443
|
+
g = { run: run, targets: [] };
|
|
9444
|
+
groups.push(g);
|
|
9445
|
+
}
|
|
9446
|
+
}
|
|
9447
|
+
g.targets.push(els[i]);
|
|
9448
|
+
}
|
|
9449
|
+
for (let i = 0; i < groups.length; i++) {
|
|
9450
|
+
const g = groups[i];
|
|
9451
|
+
if (!listRunSupportsStructuralEdit(g.run, g.targets, { columnOnly: true })) {
|
|
9452
|
+
refuseStructuralListEdit(); return;
|
|
9453
|
+
}
|
|
9454
|
+
g.range = runRangeOfBlocks({ lines, blocks, stack }, g.run);
|
|
9455
|
+
if (!g.range) return;
|
|
9456
|
+
}
|
|
9457
|
+
let lo = recs[0].startLine;
|
|
9458
|
+
let hi = recs[recs.length - 1].endLine;
|
|
9459
|
+
groups.forEach((g) => { lo = Math.min(lo, g.range.startLine); hi = Math.max(hi, g.range.endLine); });
|
|
9460
|
+
const inRun = (b) => groups.some((g) => b.startLine >= g.range.startLine && b.endLine <= g.range.endLine);
|
|
9461
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
9462
|
+
const b = blocks[i];
|
|
9463
|
+
if (b.endLine < lo || b.startLine > hi) continue;
|
|
9464
|
+
const k = recs.indexOf(b);
|
|
9465
|
+
if (k !== -1 && !isLi(els[k]) && inRun(b)) { refuseStructuralListEdit(); return; }
|
|
9466
|
+
if (k === -1 && !inRun(b)) { refuseStructuralListEdit(); return; }
|
|
9467
|
+
}
|
|
9468
|
+
|
|
9469
|
+
let changed = false;
|
|
9470
|
+
groups.forEach((g) => {
|
|
9471
|
+
g.delta = batchIndentDelta(g.targets, dir);
|
|
9472
|
+
if (g.delta !== 0) changed = true;
|
|
9473
|
+
});
|
|
9474
|
+
// Segments in document order: each list group re-serialized, each other
|
|
9475
|
+
// member's own lines (a heading re-depthed). Whatever lies between two
|
|
9476
|
+
// segments — blank lines, by the check above — is copied as it is.
|
|
9477
|
+
const segs = [];
|
|
9478
|
+
for (let i = 0; i < recs.length; i++) {
|
|
9479
|
+
if (isLi(els[i])) continue;
|
|
9480
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
9481
|
+
if (recs[i].type === 'heading' && /^#{1,6}(\s|$)/.test(src[0])) {
|
|
9482
|
+
const depth = headingDepthOf(src[0]);
|
|
9483
|
+
const next = Math.max(1, Math.min(6, depth + dir));
|
|
9484
|
+
if (next !== depth) { src[0] = withHeadingDepth(src[0], next); changed = true; }
|
|
9485
|
+
}
|
|
9486
|
+
segs.push({ start: recs[i].startLine, end: recs[i].endLine, text: src });
|
|
9487
|
+
}
|
|
9488
|
+
if (!changed) return;
|
|
9489
|
+
mutateListRun(() => {
|
|
9490
|
+
groups.forEach((g) => {
|
|
9491
|
+
if (g.delta === 0) return;
|
|
9492
|
+
const oldIndent = spanMinIndent(g.targets);
|
|
9493
|
+
g.targets.forEach((el) => setBlockIndent(el,
|
|
9494
|
+
Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + g.delta)));
|
|
9495
|
+
applyIndentClamp(g.run, g.targets, oldIndent);
|
|
9496
|
+
});
|
|
9497
|
+
});
|
|
9498
|
+
const carry = bystanderCarryOver([].concat(...groups.map((g) => g.run)), null);
|
|
9499
|
+
groups.forEach((g) => {
|
|
9500
|
+
segs.push({ start: g.range.startLine, end: g.range.endLine,
|
|
9501
|
+
text: serializeListEls(g.run, { carryOver: carry }).md.split('\n') });
|
|
9502
|
+
});
|
|
9503
|
+
segs.sort((a, b) => a.start - b.start);
|
|
9504
|
+
const out = [];
|
|
9505
|
+
let at = lo;
|
|
9506
|
+
segs.forEach((sg) => {
|
|
9507
|
+
out.push(...lines.slice(at - 1, sg.start - 1));
|
|
9508
|
+
out.push(...sg.text);
|
|
9509
|
+
at = sg.end + 1;
|
|
9510
|
+
});
|
|
9511
|
+
out.push(...lines.slice(at - 1, hi));
|
|
9512
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
|
|
9513
|
+
if (result.op === null) return;
|
|
9514
|
+
endBurstWithoutResolve();
|
|
9515
|
+
const prevLines = lines;
|
|
9516
|
+
lines = result.lines;
|
|
9517
|
+
declareCollapse({ startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine });
|
|
9518
|
+
const editRange = result.op
|
|
9519
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
9520
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
9521
|
+
delta: result.op.after.length - result.op.before.length }
|
|
9522
|
+
: undefined;
|
|
9523
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
9524
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
9525
|
+
}
|
|
9526
|
+
}
|
|
9527
|
+
|
|
9125
9528
|
async function changeHeadingDepthsInSpan(els, recs, dir) {
|
|
9126
9529
|
const first = recs[0];
|
|
9127
9530
|
const last = recs[recs.length - 1];
|
|
@@ -9179,13 +9582,14 @@
|
|
|
9179
9582
|
const els = operands.els;
|
|
9180
9583
|
const recs = operands.recs;
|
|
9181
9584
|
const shape = spanListKinds(els);
|
|
9182
|
-
//
|
|
9183
|
-
//
|
|
9184
|
-
//
|
|
9185
|
-
//
|
|
9186
|
-
//
|
|
9187
|
-
|
|
9188
|
-
|
|
9585
|
+
// v3.11: a span mixing both kinds applies each rule to its own kind (the
|
|
9586
|
+
// S3 plan's Task 7 text) in ONE commit — tabSpanAcrossLists() — so §3.4's
|
|
9587
|
+
// 「一次使用者手勢 = 恰好一個 undo op」 holds; so does a span taking items of
|
|
9588
|
+
// two lists. Both used to refuse (§3.6's 2026-08-31 ruling, which only
|
|
9589
|
+
// stood because the combined commit did not exist).
|
|
9590
|
+
if ((shape.anyLi && !shape.allLi) || (shape.allLi && !batchRunOf(els))) {
|
|
9591
|
+
await tabSpanAcrossLists(els, recs, dir); return;
|
|
9592
|
+
}
|
|
9189
9593
|
if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
|
|
9190
9594
|
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
9191
9595
|
}
|
|
@@ -11649,7 +12053,7 @@
|
|
|
11649
12053
|
if (!liveTableEl) return;
|
|
11650
12054
|
const headerRow = headerRowOf(liveTableEl);
|
|
11651
12055
|
if (!headerRow || headerRow.cells.length <= 1) {
|
|
11652
|
-
showNotice('
|
|
12056
|
+
showNotice('這是表格的最後一欄。要刪除整張表格,請用 ⠿ → 刪除。');
|
|
11653
12057
|
return;
|
|
11654
12058
|
}
|
|
11655
12059
|
currentBurst.history.snap('delete-col-pre');
|
|
@@ -11676,12 +12080,28 @@
|
|
|
11676
12080
|
if (!liveTableEl) return;
|
|
11677
12081
|
const liveRowEl = rowIndex >= 0 ? allRowsOf(liveTableEl)[rowIndex] : null;
|
|
11678
12082
|
if (!liveRowEl) return;
|
|
12083
|
+
// v3.11 (Notion does what the gesture says): deleting the header row
|
|
12084
|
+
// promotes the first body row into it; with no body rows left the table
|
|
12085
|
+
// has nothing to keep, so the whole table block goes. The last BODY row
|
|
12086
|
+
// goes like any other — a header-only table is valid GFM.
|
|
11679
12087
|
if (liveRowEl === headerRowOf(liveTableEl)) {
|
|
11680
|
-
|
|
11681
|
-
|
|
11682
|
-
|
|
11683
|
-
|
|
11684
|
-
|
|
12088
|
+
const firstBody = bodyRowsOf(liveTableEl)[0];
|
|
12089
|
+
if (!firstBody) {
|
|
12090
|
+
hideTableEdgeMenu();
|
|
12091
|
+
const tableBlockEl = liveTableEl.closest('.ed-block');
|
|
12092
|
+
if (tableBlockEl) await deleteBlockViaGutter(tableBlockEl);
|
|
12093
|
+
return;
|
|
12094
|
+
}
|
|
12095
|
+
currentBurst.history.snap('delete-header-pre');
|
|
12096
|
+
const head = liveRowEl.cells;
|
|
12097
|
+
for (let i = 0; i < head.length; i++) {
|
|
12098
|
+
const src = firstBody.cells[i];
|
|
12099
|
+
head[i].innerHTML = src ? src.innerHTML : '';
|
|
12100
|
+
}
|
|
12101
|
+
refocusAwayFromRow(liveTableEl, firstBody);
|
|
12102
|
+
deleteRow(firstBody);
|
|
12103
|
+
currentBurst.history.snap('delete-header');
|
|
12104
|
+
hideTableEdgeMenu();
|
|
11685
12105
|
return;
|
|
11686
12106
|
}
|
|
11687
12107
|
currentBurst.history.snap('delete-row-pre');
|
|
@@ -12220,12 +12640,10 @@
|
|
|
12220
12640
|
// v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
|
|
12221
12641
|
// buildTableGrip() for the S4 T1 detour into an inset grip and how it
|
|
12222
12642
|
// was undone), vertically centred on its own row; no
|
|
12223
|
-
// header special case.
|
|
12224
|
-
//
|
|
12225
|
-
//
|
|
12226
|
-
|
|
12227
|
-
// from the page. Withhold the grip there instead.
|
|
12228
|
-
if (rowEl && (rowEl !== headerRow || bodyRowsOf(tableEl).length > 0)) {
|
|
12643
|
+
// header special case. v3.11: that includes a header-only table — its
|
|
12644
|
+
// grip opens the row menu (刪除列 there removes the table); the DRAG is
|
|
12645
|
+
// what must not empty the thead, and performRowDrop() refuses it.
|
|
12646
|
+
if (rowEl) {
|
|
12229
12647
|
gripRowTableEl = tableEl;
|
|
12230
12648
|
gripRowEl = rowEl;
|
|
12231
12649
|
const r = rowEl.getBoundingClientRect();
|
|
@@ -12547,6 +12965,8 @@
|
|
|
12547
12965
|
// land on the header cell.
|
|
12548
12966
|
const liveTableEl = await ensureTableBurstOpen(tableEl, true);
|
|
12549
12967
|
if (!liveTableEl) return;
|
|
12968
|
+
// A header-only table has nowhere to move its one row to.
|
|
12969
|
+
if (!bodyRowsOf(liveTableEl).length) return;
|
|
12550
12970
|
const liveRows = allRowsOf(liveTableEl);
|
|
12551
12971
|
if (rowIndex < 0 || !liveRows[rowIndex]) return;
|
|
12552
12972
|
|
|
@@ -13060,23 +13480,12 @@
|
|
|
13060
13480
|
// SECOND home position of its own — see performListItemDrop().
|
|
13061
13481
|
if (liveDestEl && opEls.indexOf(liveDestEl) !== -1) return;
|
|
13062
13482
|
const shape = spanListKinds(opEls);
|
|
13063
|
-
//
|
|
13064
|
-
//
|
|
13065
|
-
//
|
|
13066
|
-
//
|
|
13067
|
-
//
|
|
13068
|
-
|
|
13069
|
-
// 建立副本 / 刪除 / 轉換's own message rather than a fifth move message.
|
|
13070
|
-
//
|
|
13071
|
-
// DELIBERATELY ABOVE the second home position, which is the one place this
|
|
13072
|
-
// function departs from Task 3's "answer a home position before every
|
|
13073
|
-
// gate" rule, and the departure is the point: a seam objection depends on
|
|
13074
|
-
// where the user AIMED, so refusing a put-it-back gesture with one would
|
|
13075
|
-
// be a banner for a gesture in which nothing went wrong. MIXED is a
|
|
13076
|
-
// property of the SET — it gives the same answer at every destination, so
|
|
13077
|
-
// there is no aim that would have worked and saying so is the honest
|
|
13078
|
-
// outcome. (The drop-on-a-member case is already out, above.)
|
|
13079
|
-
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
13483
|
+
// v3.11: a span mixing list items with other blocks moves too. It used to
|
|
13484
|
+
// refuse (§3.6's 2026-08-31 ruling: the blank-line rule at the run/non-run
|
|
13485
|
+
// seam had no answer). moveSpanAcrossLists() gives it one — list items in
|
|
13486
|
+
// a row are serialized as one tight list, everything else keeps its own
|
|
13487
|
+
// separators or gets one blank line — so the set has a destination.
|
|
13488
|
+
if (shape.anyLi && !shape.allLi) { await moveSpanAcrossLists(opEls, liveDestEl); return; }
|
|
13080
13489
|
// S4 Task 4 / Task 6: a li source has its own path. Its lines cannot be
|
|
13081
13490
|
// relocated verbatim (markers and ordinals are run-global), so the whole
|
|
13082
13491
|
// run is re-serialized in the new span order instead — still ONE
|
|
@@ -13176,12 +13585,11 @@
|
|
|
13176
13585
|
const seamRefusal = blockMoveSeamRefusal(
|
|
13177
13586
|
{ prev: seamDesc(live[si - 1]), next: seamDesc(live[sj + 1]) },
|
|
13178
13587
|
{ prev: seamDesc(rest[di - 1]), next: seamDesc(rest[di]) });
|
|
13179
|
-
|
|
13180
|
-
|
|
13181
|
-
|
|
13182
|
-
|
|
13183
|
-
|
|
13184
|
-
refuseStructuralListEdit(BLOCK_MOVE_DEST_SEAM_MESSAGE);
|
|
13588
|
+
// v3.11: neither seam refuses any more. Two lists that meet once this
|
|
13589
|
+
// block has left join; a block dropped between two items splits the list.
|
|
13590
|
+
// Both need list items re-serialized, so they take moveSpanAcrossLists().
|
|
13591
|
+
if (seamRefusal === 'source' || seamRefusal === 'destination') {
|
|
13592
|
+
await moveSpanAcrossLists(opEls, liveDestEl);
|
|
13185
13593
|
return;
|
|
13186
13594
|
}
|
|
13187
13595
|
const result = commitBlockMove({ lines, blocks, stack }, srcRec, destRec);
|
|
@@ -13268,6 +13676,191 @@
|
|
|
13268
13676
|
// contiguous slice of the document (listRunOf()'s own contract), so the
|
|
13269
13677
|
// members occupy CONSECUTIVE slots of `run` and "move them" is one call to
|
|
13270
13678
|
// reorderSpanRange() with a count instead of two paths.
|
|
13679
|
+
// v3.11: the ⠿ drop the narrower paths used to refuse — a paragraph into
|
|
13680
|
+
// the middle of a list (it splits), a list item out of its list or into
|
|
13681
|
+
// another one (it leaves / joins), a block whose removal makes two lists
|
|
13682
|
+
// meet (they join), a span mixing list items with other blocks, or items
|
|
13683
|
+
// of two lists. A list item takes its subtree along, as in Notion.
|
|
13684
|
+
//
|
|
13685
|
+
// ONE commit over the region the move touches: the moved span, the blocks
|
|
13686
|
+
// on either side of where it left and where it lands, and every list any of
|
|
13687
|
+
// those belongs to, whole. Inside it the blocks are laid out in their new
|
|
13688
|
+
// order; each maximal run of consecutive list items is re-serialized as one
|
|
13689
|
+
// list (serializeBlocks(), bystander bytes replayed, indents clamped so no
|
|
13690
|
+
// item is more than one level below the one above it), every other block
|
|
13691
|
+
// is its own source lines. Two blocks that were already neighbours keep the
|
|
13692
|
+
// lines that separated them; new neighbours get one blank line.
|
|
13693
|
+
async function moveSpanAcrossLists(opEls, destEl) {
|
|
13694
|
+
const all = allBlockEls();
|
|
13695
|
+
const isLi = (el) => !!el && el.getAttribute('data-block-type') === 'li';
|
|
13696
|
+
const indentOf = (el) => Number(el.getAttribute('data-indent')) || 0;
|
|
13697
|
+
const opLi = opEls.filter(isLi);
|
|
13698
|
+
const minLi = opLi.length ? spanMinIndent(opLi) : 0;
|
|
13699
|
+
const moving = opEls.slice();
|
|
13700
|
+
if (opLi.length) {
|
|
13701
|
+
subtreeBlocksAfter(moving[moving.length - 1], minLi).forEach((el) => {
|
|
13702
|
+
if (moving.indexOf(el) === -1) moving.push(el);
|
|
13703
|
+
});
|
|
13704
|
+
}
|
|
13705
|
+
const firstIdx = all.indexOf(moving[0]);
|
|
13706
|
+
const lastIdx = all.indexOf(moving[moving.length - 1]);
|
|
13707
|
+
if (firstIdx < 0 || lastIdx - firstIdx + 1 !== moving.length) {
|
|
13708
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null); return;
|
|
13709
|
+
}
|
|
13710
|
+
if (destEl && moving.indexOf(destEl) !== -1) return;
|
|
13711
|
+
const rest = all.filter((el) => moving.indexOf(el) === -1);
|
|
13712
|
+
const di = destEl ? rest.indexOf(destEl) : rest.length;
|
|
13713
|
+
if (di < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13714
|
+
if (di === firstIdx) return; // dropped where it already is
|
|
13715
|
+
|
|
13716
|
+
// The region: indices into `all`, widened until every list it touches is
|
|
13717
|
+
// inside it whole.
|
|
13718
|
+
const touched = moving.concat([all[firstIdx - 1], all[lastIdx + 1], rest[di - 1], rest[di]])
|
|
13719
|
+
.filter(Boolean);
|
|
13720
|
+
let lo = Math.min(...touched.map((el) => all.indexOf(el)));
|
|
13721
|
+
let hi = Math.max(...touched.map((el) => all.indexOf(el)));
|
|
13722
|
+
for (let grew = true; grew;) {
|
|
13723
|
+
grew = false;
|
|
13724
|
+
for (let k = lo; k <= hi; k++) {
|
|
13725
|
+
if (!isLi(all[k])) continue;
|
|
13726
|
+
const run = listRunOf(all[k]);
|
|
13727
|
+
if (!run.length) continue;
|
|
13728
|
+
const a = all.indexOf(run[0]);
|
|
13729
|
+
const b = all.indexOf(run[run.length - 1]);
|
|
13730
|
+
if (a < lo) { lo = a; grew = true; }
|
|
13731
|
+
if (b > hi) { hi = b; grew = true; }
|
|
13732
|
+
}
|
|
13733
|
+
}
|
|
13734
|
+
const regionEls = all.slice(lo, hi + 1);
|
|
13735
|
+
for (let k = 0; k < regionEls.length; k++) {
|
|
13736
|
+
if (blockOwnsNoLine(regionEls[k])) { refuseStructuralListEdit(noSourceLineMessageFor(regionEls[k])); return; }
|
|
13737
|
+
if (!blockRecOf(regionEls[k])) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13738
|
+
}
|
|
13739
|
+
const seenRuns = [];
|
|
13740
|
+
const runOf = new Map(); // each item's list BEFORE the move
|
|
13741
|
+
for (let k = 0; k < regionEls.length; k++) {
|
|
13742
|
+
if (!isLi(regionEls[k]) || runOf.has(regionEls[k])) continue;
|
|
13743
|
+
const run = listRunOf(regionEls[k]);
|
|
13744
|
+
seenRuns.push(run);
|
|
13745
|
+
run.forEach((el) => runOf.set(el, run));
|
|
13746
|
+
if (!listRunSupportsStructuralEdit(run, null, { columnOnly: true })) { refuseStructuralListEdit(); return; }
|
|
13747
|
+
}
|
|
13748
|
+
|
|
13749
|
+
// The new order.
|
|
13750
|
+
const restRegion = regionEls.filter((el) => moving.indexOf(el) === -1);
|
|
13751
|
+
const at = destEl ? restRegion.indexOf(destEl) : restRegion.length;
|
|
13752
|
+
if (at < 0) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
13753
|
+
const seq = restRegion.slice(0, at).concat(moving, restRegion.slice(at));
|
|
13754
|
+
// Indents: a moved item lands at the level of the item it is dropped in
|
|
13755
|
+
// front of (top level otherwise), keeping its own subtree's shape; then
|
|
13756
|
+
// every item is clamped to at most one level below the item above it.
|
|
13757
|
+
const base = isLi(destEl) ? indentOf(destEl) : 0;
|
|
13758
|
+
const want = new Map();
|
|
13759
|
+
let prev = -1;
|
|
13760
|
+
seq.forEach((el) => {
|
|
13761
|
+
if (isLi(el)) {
|
|
13762
|
+
let ind = moving.indexOf(el) !== -1 ? base + (indentOf(el) - minLi) : indentOf(el);
|
|
13763
|
+
ind = Math.max(0, Math.min(ind, prev + 1));
|
|
13764
|
+
want.set(el, ind);
|
|
13765
|
+
prev = ind;
|
|
13766
|
+
} else {
|
|
13767
|
+
prev = -1;
|
|
13768
|
+
}
|
|
13769
|
+
});
|
|
13770
|
+
const firstRec = blockRecOf(regionEls[0]);
|
|
13771
|
+
const lastRec = blockRecOf(regionEls[regionEls.length - 1]);
|
|
13772
|
+
const carry = bystanderCarryOver(seq.filter(isLi));
|
|
13773
|
+
const origIndent = new Map(seq.filter(isLi).map((el) => [el, indentOf(el)]));
|
|
13774
|
+
mutateListRun(() => {
|
|
13775
|
+
seq.forEach((el, k) => {
|
|
13776
|
+
if (isLi(el)) {
|
|
13777
|
+
if (want.get(el) !== indentOf(el)) setBlockIndent(el, want.get(el));
|
|
13778
|
+
// A list boundary survives only where the same two blocks still meet.
|
|
13779
|
+
const before = seq[k - 1];
|
|
13780
|
+
if (isLi(before) && all[all.indexOf(el) - 1] !== before) el.removeAttribute('data-list-start');
|
|
13781
|
+
}
|
|
13782
|
+
});
|
|
13783
|
+
});
|
|
13784
|
+
|
|
13785
|
+
const out = [];
|
|
13786
|
+
const movedIds = moving.map((el) => el.getAttribute('data-block-id'));
|
|
13787
|
+
let movedFrom = null;
|
|
13788
|
+
let movedTo = null;
|
|
13789
|
+
const mark = (id, idx) => {
|
|
13790
|
+
if (movedIds.indexOf(id) !== -1) {
|
|
13791
|
+
if (movedFrom === null || idx < movedFrom) movedFrom = idx;
|
|
13792
|
+
if (movedTo === null || idx > movedTo) movedTo = idx;
|
|
13793
|
+
}
|
|
13794
|
+
};
|
|
13795
|
+
// Two items that were already neighbours in two DIFFERENT lists stay two
|
|
13796
|
+
// lists, with whatever separated them.
|
|
13797
|
+
const keepsApart = (x, y) => all[all.indexOf(x) + 1] === y && runOf.get(x) !== runOf.get(y);
|
|
13798
|
+
let lastEl = null;
|
|
13799
|
+
for (let k = 0; k < seq.length;) {
|
|
13800
|
+
let unit;
|
|
13801
|
+
let unitLines;
|
|
13802
|
+
let meta = null;
|
|
13803
|
+
if (isLi(seq[k])) {
|
|
13804
|
+
let j = k + 1;
|
|
13805
|
+
while (j < seq.length && isLi(seq[j]) && !keepsApart(seq[j - 1], seq[j])) j++;
|
|
13806
|
+
unit = seq.slice(k, j);
|
|
13807
|
+
const orig = runOf.get(unit[0]);
|
|
13808
|
+
if (orig && orig.length === unit.length && orig.every((el, n) => el === unit[n] &&
|
|
13809
|
+
moving.indexOf(el) === -1 && want.get(el) === origIndent.get(el))) {
|
|
13810
|
+
// A list the move did not touch keeps its own bytes.
|
|
13811
|
+
const r0 = blockRecOf(unit[0]);
|
|
13812
|
+
const r1 = blockRecOf(unit[unit.length - 1]);
|
|
13813
|
+
unitLines = lines.slice(r0.startLine - 1, r1.endLine);
|
|
13814
|
+
} else {
|
|
13815
|
+
const res = serializeListEls(unit, { carryOver: carry });
|
|
13816
|
+
unitLines = res.md.split('\n');
|
|
13817
|
+
meta = res.lineMeta;
|
|
13818
|
+
}
|
|
13819
|
+
k = j;
|
|
13820
|
+
} else {
|
|
13821
|
+
unit = [seq[k]];
|
|
13822
|
+
const r = blockRecOf(seq[k]);
|
|
13823
|
+
unitLines = lines.slice(r.startLine - 1, r.endLine);
|
|
13824
|
+
k++;
|
|
13825
|
+
}
|
|
13826
|
+
if (lastEl) {
|
|
13827
|
+
const a = blockRecOf(lastEl);
|
|
13828
|
+
const b = blockRecOf(unit[0]);
|
|
13829
|
+
if (all[all.indexOf(lastEl) + 1] === unit[0]) out.push(...lines.slice(a.endLine, b.startLine - 1));
|
|
13830
|
+
else out.push('');
|
|
13831
|
+
}
|
|
13832
|
+
const base0 = out.length;
|
|
13833
|
+
unitLines.forEach((l, n) => {
|
|
13834
|
+
mark(meta ? (meta[n] && meta[n].blockId) : unit[0].getAttribute('data-block-id'), base0 + n);
|
|
13835
|
+
});
|
|
13836
|
+
out.push(...unitLines);
|
|
13837
|
+
lastEl = unit[unit.length - 1];
|
|
13838
|
+
}
|
|
13839
|
+
let lead = 0;
|
|
13840
|
+
if (seq[0] !== regionEls[0] && firstRec.startLine > 1 &&
|
|
13841
|
+
String(lines[firstRec.startLine - 2]).trim() !== '') { out.unshift(''); lead = 1; }
|
|
13842
|
+
if (seq[seq.length - 1] !== regionEls[regionEls.length - 1] && lastRec.endLine < lines.length &&
|
|
13843
|
+
String(lines[lastRec.endLine]).trim() !== '') out.push('');
|
|
13844
|
+
|
|
13845
|
+
const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'));
|
|
13846
|
+
if (result.op === null) return;
|
|
13847
|
+
endBurstWithoutResolve();
|
|
13848
|
+
const prevLines = lines;
|
|
13849
|
+
lines = result.lines;
|
|
13850
|
+
if (movedFrom !== null) {
|
|
13851
|
+
declareCollapse({ startLine: firstRec.startLine + lead + movedFrom,
|
|
13852
|
+
endLine: firstRec.startLine + lead + movedTo });
|
|
13853
|
+
}
|
|
13854
|
+
const editRange = result.op
|
|
13855
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
13856
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
13857
|
+
delta: result.op.after.length - result.op.before.length }
|
|
13858
|
+
: undefined;
|
|
13859
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
13860
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
13861
|
+
}
|
|
13862
|
+
}
|
|
13863
|
+
|
|
13271
13864
|
async function performListItemDrop(liEls, destEl) {
|
|
13272
13865
|
const liEl = liEls[0];
|
|
13273
13866
|
const lastLiEl = liEls[liEls.length - 1];
|
|
@@ -13287,7 +13880,8 @@
|
|
|
13287
13880
|
// convertBlockViaMenu() gives, through the same message: the commit
|
|
13288
13881
|
// re-serializes ONE run's line range and a member outside it would be
|
|
13289
13882
|
// rewritten by a range that does not cover it.
|
|
13290
|
-
|
|
13883
|
+
// v3.11: items of two lists move together through moveSpanAcrossLists().
|
|
13884
|
+
if (srcIdxs.indexOf(-1) !== -1) { await moveSpanAcrossLists(liEls, destEl); return; }
|
|
13291
13885
|
// ...and they must be CONSECUTIVE slots of it. Guaranteed today by
|
|
13292
13886
|
// spanIsContiguous() plus listRunOf() returning a contiguous slice, and
|
|
13293
13887
|
// checked anyway rather than asserted in prose: reorderSpanRange() takes a
|
|
@@ -13356,7 +13950,9 @@
|
|
|
13356
13950
|
// {mode:'append'} target when a block follows the run. Task 5 gave them
|
|
13357
13951
|
// their own message — the remedy is "stay inside your own list", which
|
|
13358
13952
|
// neither seam message says.
|
|
13359
|
-
|
|
13953
|
+
// v3.11: out of its own list — into another list, or anywhere else —
|
|
13954
|
+
// used to refuse with BLOCK_MOVE_OUT_OF_RUN_MESSAGE.
|
|
13955
|
+
if (insertAt < 0) { await moveSpanAcrossLists(liEls, destEl); return; }
|
|
13360
13956
|
// `null` is the order-is-unchanged answer — the two home positions above
|
|
13361
13957
|
// reach this by another road (an item whose next block is a sibling), and
|
|
13362
13958
|
// it stays SILENT for the same reason.
|
|
@@ -13419,7 +14015,9 @@
|
|
|
13419
14015
|
indent: clamped.has(el) ? clamped.get(el)
|
|
13420
14016
|
: (Number(el.getAttribute('data-indent')) || 0),
|
|
13421
14017
|
})))) {
|
|
13422
|
-
|
|
14018
|
+
// v3.11: the generic move re-anchors instead of refusing (its indent
|
|
14019
|
+
// clamp keeps every item within one level of the item above it).
|
|
14020
|
+
await moveSpanAcrossLists(liEls, destEl);
|
|
13423
14021
|
return;
|
|
13424
14022
|
}
|
|
13425
14023
|
// S4 review round (2026-09-01): this WRITE is load-bearing, and it took a
|
|
@@ -13499,7 +14097,7 @@
|
|
|
13499
14097
|
function movedLiRangeAfterReorder(span, liEls, carryOver, range) {
|
|
13500
14098
|
const ids = liEls.map((el) => el.getAttribute('data-block-id'));
|
|
13501
14099
|
if (ids.indexOf(null) !== -1) return null;
|
|
13502
|
-
const { lineMeta } =
|
|
14100
|
+
const { lineMeta } = serializeListEls(span, { carryOver: carryOver || null });
|
|
13503
14101
|
let first = -1;
|
|
13504
14102
|
let last = -1;
|
|
13505
14103
|
lineMeta.forEach((m, i) => {
|
|
@@ -13724,17 +14322,10 @@
|
|
|
13724
14322
|
}
|
|
13725
14323
|
// A plain press-release with no drag threshold crossed: open the menu
|
|
13726
14324
|
// for whatever zone was hit at pointerdown.
|
|
14325
|
+
// v3.11: the header row's grip opens the same menu — deleting the header
|
|
14326
|
+
// promotes the first body row (runDeleteRow()).
|
|
13727
14327
|
if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
|
|
13728
|
-
else
|
|
13729
|
-
// The row menu's only item is "delete row", and the header row can
|
|
13730
|
-
// never be deleted — showing it would just be an empty box. A plain
|
|
13731
|
-
// click on the header grip highlights the row instead.
|
|
13732
|
-
// clearEdgeHighlight() runs first so re-clicking a different header
|
|
13733
|
-
// (or a different row's menu having been open) doesn't stack
|
|
13734
|
-
// highlights within the same session.
|
|
13735
|
-
clearEdgeHighlight();
|
|
13736
|
-
highlightRow(st.hit.rowEl);
|
|
13737
|
-
} else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
14328
|
+
else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
13738
14329
|
});
|
|
13739
14330
|
|
|
13740
14331
|
// Review fix (Critical): the browser/OS can ABORT a gesture outright —
|