@jackuait/blok 0.10.7 → 0.10.9

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.
Files changed (49) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-oWXfRfnM.mjs → blok-DbRn9adY.mjs} +2681 -2238
  3. package/dist/chunks/{constants-BQ1-lyZI.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{core-C942GvJO.mjs → core-B7mxBIHA.mjs} +1 -1
  5. package/dist/chunks/{engine-javascript-Dd6ViPCH.mjs → engine-javascript-Bmmg8uL9.mjs} +1 -1
  6. package/dist/chunks/{i18next-loader-CIXsptng.mjs → i18next-loader-453gJdot.mjs} +1 -1
  7. package/dist/chunks/{tools-MuBQQyZ-.mjs → tools-D0W3_dlA.mjs} +504 -499
  8. package/dist/full.mjs +3 -3
  9. package/dist/react.mjs +3 -3
  10. package/dist/tools.mjs +2 -2
  11. package/package.json +3 -6
  12. package/src/components/block/index.ts +36 -0
  13. package/src/components/blocks.ts +191 -5
  14. package/src/components/modules/api/blocks.ts +20 -5
  15. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  16. package/src/components/modules/blockManager/blockManager.ts +364 -23
  17. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  18. package/src/components/modules/blockManager/operations.ts +223 -26
  19. package/src/components/modules/blockManager/types.ts +13 -1
  20. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  21. package/src/components/modules/drag/DragController.ts +209 -8
  22. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  23. package/src/components/modules/paste/handlers/base.ts +48 -20
  24. package/src/components/modules/paste/handlers/blok-data-handler.ts +184 -44
  25. package/src/components/modules/paste/index.ts +20 -0
  26. package/src/components/modules/renderer.ts +9 -1
  27. package/src/components/modules/saver.ts +75 -5
  28. package/src/components/modules/toolbar/index.ts +41 -60
  29. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  30. package/src/components/modules/yjs/block-observer.ts +87 -23
  31. package/src/components/modules/yjs/document-store.ts +37 -11
  32. package/src/components/modules/yjs/index.ts +83 -7
  33. package/src/components/modules/yjs/types.ts +35 -2
  34. package/src/components/modules/yjs/undo-history.ts +116 -5
  35. package/src/components/utils/data-model-transform.ts +247 -35
  36. package/src/components/utils/hierarchy-invariant.ts +137 -0
  37. package/src/markdown/markdown-handler.ts +9 -2
  38. package/src/styles/main.css +5 -0
  39. package/src/tools/callout/constants.ts +0 -1
  40. package/src/tools/callout/dom-builder.ts +1 -11
  41. package/src/tools/callout/index.ts +0 -6
  42. package/src/tools/header/index.ts +14 -1
  43. package/src/tools/table/table-operations.ts +9 -4
  44. package/src/tools/toggle/constants.ts +2 -1
  45. package/src/tools/toggle/dom-builder.ts +7 -0
  46. package/src/tools/toggle/index.ts +14 -1
  47. package/src/tools/toggle/toggle-lifecycle.ts +24 -0
  48. /package/dist/chunks/{lightweight-i18n-DTYoSr_o.mjs → lightweight-i18n-DSjG0iTr.mjs} +0 -0
  49. /package/dist/chunks/{objectWithoutProperties-D0XxKB4n.mjs → objectWithoutProperties-Dci1-l7D.mjs} +0 -0
@@ -43,7 +43,7 @@ export interface SyncHandlers {
43
43
  /** Called to update block indentation */
44
44
  updateIndentation: (block: Block) => void;
45
45
  /** Called to set the parent of a block, updating contentIds and DOM placement */
46
- setBlockParent: (block: Block, parentId: string) => void;
46
+ setBlockParent: (block: Block, parentId: string | null) => void;
47
47
  /** Called to replace a block at a specific index with a new block instance */
48
48
  replaceBlock: (index: number, newBlock: Block) => void;
49
49
  /** Called when a block is removed during undo/redo (before DOM removal) */
@@ -192,12 +192,22 @@ export class BlockYjsSync {
192
192
  }
193
193
 
194
194
  /**
195
- * Subscribe to Yjs changes for undo/redo DOM synchronization
195
+ * Subscribe to Yjs changes for undo/redo and remote DOM synchronization.
196
+ *
197
+ * Accepts undo/redo (local-history replay) AND remote origins (changes
198
+ * from other clients). Local-origin events are filtered because the
199
+ * in-memory state was already mutated before the Yjs write landed —
200
+ * re-applying would thrash DOM and undo stacks.
201
+ *
196
202
  * @returns unsubscribe function
197
203
  */
198
204
  public subscribe(): () => void {
199
205
  return this.dependencies.YjsManager.onBlocksChanged((event: BlockChangeEvent) => {
200
- if (event.origin === 'undo' || event.origin === 'redo') {
206
+ if (
207
+ event.origin === 'undo' ||
208
+ event.origin === 'redo' ||
209
+ event.origin === 'remote'
210
+ ) {
201
211
  this.syncBlockFromYjs(event);
202
212
  }
203
213
  });
@@ -250,6 +260,41 @@ export class BlockYjsSync {
250
260
  const lastEditedAt = yblock.get('lastEditedAt') as number | undefined;
251
261
  const lastEditedBy = (yblock.get('lastEditedBy') as string | undefined) ?? null;
252
262
 
263
+ /**
264
+ * Angle 1 fix: reconcile parentId drift BEFORE data/tunes updates.
265
+ *
266
+ * A remote client may have reparented this block (e.g. dragged it into a
267
+ * callout) and the Yjs record now reflects the new `parentId`, but the
268
+ * local mirror's `block.parentId` is stale. Without this reconciliation
269
+ * the next save runs hierarchy validation, finds the parent's contentIds
270
+ * does not list the child, and ejects the child from its parent — the
271
+ * same corruption family as the callout paste bug.
272
+ *
273
+ * Route through the canonical setBlockParent handler so contentIds, DOM
274
+ * placement, and indentation all stay consistent. Must run BEFORE any
275
+ * composeBlock fallback below, otherwise the replacement would carry
276
+ * over the stale `block.parentId`.
277
+ *
278
+ * Fix 4: distinguish "key missing" from "explicit null". A missing
279
+ * `parentId` key means the Yjs record has no authoritative value —
280
+ * treat as a no-op. Explicit null means "reparent to root".
281
+ *
282
+ * Fix 3: run the reconcile inside withAtomicOperation so
283
+ * isSyncingFromYjs is true for the duration. Without this, the
284
+ * hierarchy's onParentChanged listener echoes a fresh Yjs write,
285
+ * polluting the undo stack and potentially looping.
286
+ */
287
+ if (yblock.has('parentId')) {
288
+ const rawParentId = yblock.get('parentId') as string | null | undefined;
289
+ const remoteParentId: string | null = rawParentId === undefined ? null : rawParentId;
290
+
291
+ if (remoteParentId !== block.parentId) {
292
+ this.withAtomicOperation(() => {
293
+ this.handlers.setBlockParent(block, remoteParentId);
294
+ });
295
+ }
296
+ }
297
+
253
298
  // Check if tunes have changed - if so, we need to recreate the block
254
299
  // because tunes are instantiated during block construction
255
300
  const currentTunes = block.preservedTunes;
@@ -32,6 +32,26 @@ export class DragController extends Module {
32
32
  private springLoader: ToggleSpringLoader = new ToggleSpringLoader();
33
33
  private listItemDescendants: ListItemDescendants | null = null;
34
34
  private boundHandlers: BoundHandlers | null = null;
35
+ /**
36
+ * Tracks the active mousedown cleanup per drag-handle element.
37
+ *
38
+ * The toolbar reuses a single settings-toggler element across blocks, so
39
+ * `setupDragHandle` gets called repeatedly for the same element with
40
+ * different `Block` references. Any stale listener from a previous block
41
+ * must be removed before attaching a new one — otherwise multiple
42
+ * listeners fire on the same mousedown and the oldest (wrong, unrelated)
43
+ * block wins the race inside the state machine.
44
+ */
45
+ private dragHandleCleanups: WeakMap<HTMLElement, () => void> = new WeakMap();
46
+
47
+ /**
48
+ * Unsubscribers returned by `Block.addDestroyCallback` for every block
49
+ * participating in the current drag. If any source block is destroyed
50
+ * mid-drag (Yjs remote update, block conversion, blockManager.update)
51
+ * the associated callback cancels the drag before the state machine's
52
+ * stale reference can reach the drop handler.
53
+ */
54
+ private sourceBlockDestroyUnsubs: Array<() => void> = [];
35
55
 
36
56
  public get isDragging(): boolean {
37
57
  return isActuallyDragging(this.stateMachine.getState());
@@ -74,7 +94,25 @@ export class DragController extends Module {
74
94
  }
75
95
  }
76
96
 
97
+ /**
98
+ * Invoked when any participating source block is destroyed mid-drag.
99
+ * Cancels the drag cleanly so the state machine never reaches mouseup
100
+ * with a stale Block reference.
101
+ */
102
+ private onSourceBlockDestroyed(): void {
103
+ if (!isDragActive(this.stateMachine.getState())) {
104
+ return;
105
+ }
106
+
107
+ this.cleanup(true, true);
108
+ }
109
+
77
110
  public setupDragHandle(dragHandle: HTMLElement, block: Block): () => void {
111
+ // Remove any previously-registered listener on this element so we never
112
+ // end up with two handlers racing on the same mousedown. See the comment
113
+ // on `dragHandleCleanups` for the failure mode this guards against.
114
+ this.dragHandleCleanups.get(dragHandle)?.();
115
+
78
116
  const onMouseDown = (e: MouseEvent): void => {
79
117
  // Only handle left mouse button
80
118
  if (e.button !== 0) {
@@ -86,14 +124,54 @@ export class DragController extends Module {
86
124
  return;
87
125
  }
88
126
 
127
+ /**
128
+ * Resolve the source block FRESH at mousedown time by reading the
129
+ * `data-blok-id` off the drag handle's nearest block-holder ancestor.
130
+ * The closure-captured `block` parameter is only a hint — the Toolbar
131
+ * shares ONE settings-toggler across every block and re-parents it on
132
+ * hover, so by the time the user presses down the handle can live in
133
+ * a different block than the one passed at bind time. Trusting the
134
+ * closure alone is the last theoretical path to "wrong block dropped";
135
+ * this lookup kills it. If the handle has no id ancestor (orphaned
136
+ * fixture in tests, or a pre-attachment race) fall back to the closure
137
+ * block — it's the best available reference.
138
+ *
139
+ * Zombie-id guard (Layer 8): if the handle DOES sit inside a holder
140
+ * with a `data-blok-id` but that id resolves to no Block (the block
141
+ * was destroyed and its DOM not yet reaped, or yjs deleted it
142
+ * mid-hover), the closure hint is almost certainly ALSO stale. Abort
143
+ * the drag — a known-dead id is a stronger signal than silence.
144
+ */
145
+ const holderAncestor = dragHandle.closest(`[${DATA_ATTR.id}]`);
146
+ const liveId = holderAncestor?.getAttribute(DATA_ATTR.id) ?? null;
147
+
148
+ if (liveId !== null) {
149
+ const liveBlock = this.Blok.BlockManager.getBlockById(liveId);
150
+
151
+ if (liveBlock === undefined) {
152
+ return;
153
+ }
154
+
155
+ this.startDragTracking(e, liveBlock);
156
+
157
+ return;
158
+ }
159
+
89
160
  this.startDragTracking(e, block);
90
161
  };
91
162
 
92
163
  dragHandle.addEventListener('mousedown', onMouseDown);
93
164
 
94
- return (): void => {
165
+ const cleanup = (): void => {
95
166
  dragHandle.removeEventListener('mousedown', onMouseDown);
167
+ if (this.dragHandleCleanups.get(dragHandle) === cleanup) {
168
+ this.dragHandleCleanups.delete(dragHandle);
169
+ }
96
170
  };
171
+
172
+ this.dragHandleCleanups.set(dragHandle, cleanup);
173
+
174
+ return cleanup;
97
175
  }
98
176
 
99
177
  private startDragTracking(e: MouseEvent, block: Block): void {
@@ -166,6 +244,15 @@ export class DragController extends Module {
166
244
  e.clientY
167
245
  );
168
246
 
247
+ // Subscribe to destruction of every participating block. If any source
248
+ // is replaced/destroyed mid-drag, cancel the drag immediately so the
249
+ // state machine never holds a stale Block reference at drop time.
250
+ this.sourceBlockDestroyUnsubs = blocksToMove
251
+ .filter((b): b is Block & { addDestroyCallback: (cb: () => void) => () => void } =>
252
+ typeof (b as { addDestroyCallback?: unknown }).addDestroyCallback === 'function'
253
+ )
254
+ .map((b) => b.addDestroyCallback(() => this.onSourceBlockDestroyed()));
255
+
169
256
  // Initialize auto-scroll with scrollable container
170
257
  const scrollContainer = findScrollableAncestor(this.Blok.UI.nodes.wrapper);
171
258
  this.autoScroll = new AutoScroll(scrollContainer);
@@ -355,6 +442,43 @@ export class DragController extends Module {
355
442
  sourceBlocks: Block[],
356
443
  targetBlock: Block,
357
444
  edge: 'top' | 'bottom'
445
+ ): void {
446
+ // History integration: wrap the entire drop (array move + every
447
+ // subsequent `setBlockParent`) in a single `YjsManager.transactMoves`
448
+ // group so the user's drag lands as ONE atomic undo entry.
449
+ //
450
+ // Without this wrapper, a drag-reparent emits two independent entries
451
+ // on two separate history stacks — `BlockManager.move` records to the
452
+ // custom `moveUndoStack`, while `BlockManager.setBlockParent` writes
453
+ // `parentId` / `contentIds` through `YjsManager.transact('local')` which
454
+ // lands on the Y.UndoManager stack. `UndoHistory.undo()` pops the
455
+ // custom stack first and returns, so the first Cmd+Z restores the
456
+ // block's flat position but leaves `parentId` pointing at the new
457
+ // parent. A second Cmd+Z is needed to finish reversing the drag.
458
+ //
459
+ // Wrapping in `transactMoves` opens a move group AND sets the
460
+ // `isMoveGroupActive` flag on the YjsManager; `YjsSyncCoordinator`
461
+ // routes setBlockParent's Yjs writes through `transactWithoutCapture`
462
+ // while that flag is true, so the parent change attaches to the move
463
+ // entry instead of landing on Y.UndoManager as a separate stack item.
464
+ const yjsManager = this.Blok.YjsManager;
465
+
466
+ if (yjsManager !== undefined && typeof yjsManager.transactMoves === 'function') {
467
+ yjsManager.transactMoves(() => {
468
+ this.handleDropImpl(sourceBlock, sourceBlocks, targetBlock, edge);
469
+ });
470
+
471
+ return;
472
+ }
473
+
474
+ this.handleDropImpl(sourceBlock, sourceBlocks, targetBlock, edge);
475
+ }
476
+
477
+ private handleDropImpl(
478
+ sourceBlock: Block,
479
+ sourceBlocks: Block[],
480
+ targetBlock: Block,
481
+ edge: 'top' | 'bottom'
358
482
  ): void {
359
483
  const isMultiBlockDrag = sourceBlocks.length > 1;
360
484
 
@@ -364,6 +488,22 @@ export class DragController extends Module {
364
488
  }
365
489
  const result = this.operations.moveBlocks(sourceBlocks, targetBlock, edge);
366
490
 
491
+ // Layer 13: stale-drop abort guard.
492
+ //
493
+ // When moveBlocks aborts because the target or a source went stale mid-drag
494
+ // (Layer 9), it returns `{ movedBlocks: [], targetIndex: -1 }`. Everything
495
+ // downstream in handleDrop assumes the move succeeded:
496
+ // - resolveParentForDrop reads `targetBlock.parentId` (may be stale)
497
+ // - getBlockByIndex(-1) returns undefined → a11y guard catches it
498
+ // - Toolbar.moveAndOpen fires on a possibly-dead source holder
499
+ //
500
+ // None of those cause wrong-block-dropped, but they leak stale state into
501
+ // the toolbar and a11y layers. Abort cleanly so "moveBlocks aborted" has a
502
+ // single, observable contract: nothing happens downstream.
503
+ if (result.movedBlocks.length === 0) {
504
+ return;
505
+ }
506
+
367
507
  // Update parent-child relationships after move
368
508
  const newParentId = this.resolveParentForDrop(targetBlock, edge, sourceBlocks);
369
509
  const movedBlockIds = new Set(result.movedBlocks.map(b => b.id));
@@ -504,24 +644,81 @@ export class DragController extends Module {
504
644
  if (!this.operations) {
505
645
  return;
506
646
  }
507
- const result = await this.operations.duplicateBlocks(sourceBlocks, targetBlock, edge);
647
+
648
+ // Run the async prep (block.save() awaits + stale guards) OUTSIDE the
649
+ // undo group — transactForTool is a synchronous bracket and awaiting
650
+ // inside it would leak writes out of the group. The returned plan holds
651
+ // everything `applyDuplicates` needs to run its inserts + reparents
652
+ // synchronously inside the bracket below.
653
+ const prep = await this.operations.prepareDuplicates(sourceBlocks, targetBlock, edge);
654
+
655
+ if (prep.aborted) {
656
+ return;
657
+ }
658
+
659
+ // History integration: wrap the sync tail of the alt-drag — every insert
660
+ // from `applyDuplicates` AND the follow-up `setBlockParent` loop that
661
+ // reparents duplicates to the drop target — in a single
662
+ // `BlockManager.transactForTool` group.
663
+ //
664
+ // Without this wrapper, an alt-drag fragments into N+M independent Y.UndoManager
665
+ // entries (one per insert inside `applyDuplicates`, one per setBlockParent in
666
+ // the reparent loop), so undoing the operation requires N+M Cmd+Z presses.
667
+ //
668
+ // `transactForTool` suppresses per-insert `stopCapturing` and brackets the
669
+ // group with `stopCapturing` boundaries before/after — exactly the right
670
+ // primitive for pure-creation bursts like duplicate (whereas `transactMoves`
671
+ // is move-specific and not applicable here).
672
+ const resultRef: { current: { duplicatedBlocks: Block[]; targetIndex: number } } = {
673
+ current: {
674
+ duplicatedBlocks: [],
675
+ targetIndex: prep.baseInsertIndex,
676
+ },
677
+ };
678
+
679
+ const applyAndReparent = (): void => {
680
+ if (!this.operations) {
681
+ return;
682
+ }
683
+ resultRef.current = this.operations.applyDuplicates(prep);
684
+
685
+ if (resultRef.current.duplicatedBlocks.length === 0) {
686
+ return;
687
+ }
688
+
689
+ // Set parent relationships for duplicated blocks
690
+ const dropParentId = this.resolveParentForDrop(targetBlock, edge, sourceBlocks);
691
+
692
+ for (const dupBlock of resultRef.current.duplicatedBlocks) {
693
+ if (dupBlock.parentId === dropParentId) {
694
+ continue;
695
+ }
696
+ this.Blok.BlockManager.setBlockParent(dupBlock, dropParentId);
697
+ }
698
+ };
699
+
700
+ if (typeof this.Blok.BlockManager.transactForTool === 'function') {
701
+ this.Blok.BlockManager.transactForTool(applyAndReparent);
702
+ } else {
703
+ applyAndReparent();
704
+ }
705
+
706
+ const result = resultRef.current;
508
707
 
509
708
  if (result.duplicatedBlocks.length === 0) {
510
709
  return;
511
710
  }
512
711
 
513
- // Set parent relationships for duplicated blocks
712
+ // Recompute the affected-parent set from the final duplicate state so
713
+ // toggle tools still receive their `rendered` nudge after the group
714
+ // closes. Only parents that actually received a duplicate need notifying.
514
715
  const newParentId = this.resolveParentForDrop(targetBlock, edge, sourceBlocks);
515
716
  const affectedParentIds = new Set<string>();
516
717
 
517
718
  for (const dupBlock of result.duplicatedBlocks) {
518
- if (dupBlock.parentId === newParentId) {
519
- continue;
520
- }
521
- if (newParentId !== null) {
719
+ if (dupBlock.parentId === newParentId && newParentId !== null) {
522
720
  affectedParentIds.add(newParentId);
523
721
  }
524
- this.Blok.BlockManager.setBlockParent(dupBlock, newParentId);
525
722
  }
526
723
 
527
724
  // Notify affected parent blocks so toggle tools update their visual state
@@ -621,6 +818,10 @@ export class DragController extends Module {
621
818
  this.boundHandlers = null;
622
819
  }
623
820
 
821
+ // Drop all destroy-callback subscriptions on participating blocks.
822
+ this.sourceBlockDestroyUnsubs.forEach((unsub) => unsub());
823
+ this.sourceBlockDestroyUnsubs = [];
824
+
624
825
  const sourceBlock = this.stateMachine.getSourceBlock();
625
826
  this.stateMachine.reset();
626
827
 
@@ -20,6 +20,25 @@ export interface DuplicateResult {
20
20
  targetIndex: number;
21
21
  }
22
22
 
23
+ /**
24
+ * Precomputed duplicate plan — all the async work (block.save() awaits,
25
+ * stale-reference guards) is done, leaving only synchronous Yjs writes for
26
+ * `applyDuplicates`. This split lets `handleDuplicate` wrap the sync tail
27
+ * in `BlockManager.transactForTool` so every insert + setBlockParent call
28
+ * collapses into one undo stack item.
29
+ */
30
+ export interface DuplicatePreparation {
31
+ sortedBlocks: Block[];
32
+ sourceIds: Set<string>;
33
+ validResults: Array<{
34
+ saved: { data: Record<string, unknown>; tunes: Record<string, unknown> };
35
+ toolName: string;
36
+ }>;
37
+ baseInsertIndex: number;
38
+ /** null when pre-save stale guards aborted or post-save liveTargetIndex was -1. */
39
+ aborted: boolean;
40
+ }
41
+
23
42
  export interface BlockManagerAdapter {
24
43
  getBlockIndex(block: Block): number;
25
44
  getBlockByIndex(index: number): Block | undefined;
@@ -71,6 +90,24 @@ export class DragOperations {
71
90
  targetBlock: Block,
72
91
  edge: 'top' | 'bottom'
73
92
  ): MoveResult {
93
+ // Stale-reference guard: if any source or the target has been replaced
94
+ // mid-drag (Yjs remote update, blockManager.update, tool conversion),
95
+ // getBlockIndex returns -1. Calling blockManager.move(N, -1) would invoke
96
+ // Array.splice(-1, 1), which removes the LAST block in the array — this
97
+ // is the root cause of the "completely unrelated block dropped" bug.
98
+ // Abort cleanly instead of silently moving the wrong block.
99
+ if (this.blockManager.getBlockIndex(targetBlock) === -1) {
100
+ return { movedBlocks: [], targetIndex: -1 };
101
+ }
102
+
103
+ const hasStaleSource = sourceBlocks.some(
104
+ (block) => this.blockManager.getBlockIndex(block) === -1
105
+ );
106
+
107
+ if (hasStaleSource) {
108
+ return { movedBlocks: [], targetIndex: -1 };
109
+ }
110
+
74
111
  if (sourceBlocks.length === 1) {
75
112
  return this.moveSingleBlock(sourceBlocks[0], targetBlock, edge);
76
113
  }
@@ -79,26 +116,57 @@ export class DragOperations {
79
116
  }
80
117
 
81
118
  /**
82
- * Duplicates blocks at a new position
83
- * @param sourceBlocks - Blocks to duplicate
84
- * @param targetBlock - Block to insert duplicates before/after
85
- * @param edge - Edge of target ('top' or 'bottom')
86
- * @returns Result with duplicated blocks and target index
119
+ * Async phase of alt-drag duplicate. Runs all `block.save()` awaits and both
120
+ * stale-reference guards (Layer 11 pre-save + Layer 12 post-save), then
121
+ * returns a fully-computed plan. The returned plan is consumed by the
122
+ * synchronous {@link applyDuplicates} that split lets callers wrap the
123
+ * Yjs-touching tail in a single `BlockManager.transactForTool` group so one
124
+ * alt-drag collapses into one undo stack item.
125
+ *
126
+ * When either stale guard trips, returns `{ aborted: true, ... }`; callers
127
+ * should skip `applyDuplicates` and report an empty result.
87
128
  */
88
- async duplicateBlocks(
129
+ async prepareDuplicates(
89
130
  sourceBlocks: Block[],
90
131
  targetBlock: Block,
91
132
  edge: 'top' | 'bottom'
92
- ): Promise<DuplicateResult> {
133
+ ): Promise<DuplicatePreparation> {
134
+ // Stale-reference guard (alt+drag variant of the wrong-block-dropped bug).
135
+ // Same failure mode as moveBlocks, different splice: targetIndex === -1
136
+ // produces baseInsertIndex -1 or 0, and Blocks.insert(-1, block) calls
137
+ // Array.splice(-1, 0, block) — inserting the block BEFORE the LAST slot.
138
+ // That silently diverges the flat array from the DOM, so the next move()
139
+ // indexOf lookup points at the wrong slot and drops an unrelated block.
140
+ // Abort cleanly instead of duplicating stale data at the wrong position.
141
+ if (this.blockManager.getBlockIndex(targetBlock) === -1) {
142
+ return {
143
+ sortedBlocks: [],
144
+ sourceIds: new Set(),
145
+ validResults: [],
146
+ baseInsertIndex: -1,
147
+ aborted: true,
148
+ };
149
+ }
150
+
151
+ const hasStaleSource = sourceBlocks.some(
152
+ (block) => this.blockManager.getBlockIndex(block) === -1
153
+ );
154
+
155
+ if (hasStaleSource) {
156
+ return {
157
+ sortedBlocks: [],
158
+ sourceIds: new Set(),
159
+ validResults: [],
160
+ baseInsertIndex: -1,
161
+ aborted: true,
162
+ };
163
+ }
164
+
93
165
  // Sort blocks by current index to preserve order
94
166
  const sortedBlocks = [...sourceBlocks].sort((a, b) =>
95
167
  this.blockManager.getBlockIndex(a) - this.blockManager.getBlockIndex(b)
96
168
  );
97
169
 
98
- // Calculate target insertion point
99
- const targetIndex = this.blockManager.getBlockIndex(targetBlock);
100
- const baseInsertIndex = edge === 'top' ? targetIndex : targetIndex + 1;
101
-
102
170
  // Save all blocks concurrently and filter out failures
103
171
  const saveResults = await Promise.all(
104
172
  sortedBlocks.map(async (block) => {
@@ -115,21 +183,69 @@ export class DragOperations {
115
183
  })
116
184
  );
117
185
 
186
+ // Post-save staleness guard (Layer 12).
187
+ //
188
+ // `block.save()` is async — during those awaits, the blocks array can mutate
189
+ // via a Yjs remote update, undo/redo, or a tool-conversion callback. The
190
+ // pre-save guard above only proves the target was alive when we began; by
191
+ // the time Promise.all resolves, the target may be gone or at a different
192
+ // index.
193
+ //
194
+ // Using a pre-save `baseInsertIndex` against a mutated array would:
195
+ // - insert at a stale absolute slot → divergence between flat array and DOM
196
+ // - if target was destroyed: `getBlockIndex === -1` → `splice(-1, 0, block)`
197
+ // inserts BEFORE the last element, same wrong-block-dropped mode as move.
198
+ //
199
+ // Always recompute from the live index after the save awaits resolve.
200
+ const liveTargetIndex = this.blockManager.getBlockIndex(targetBlock);
201
+
202
+ if (liveTargetIndex === -1) {
203
+ return {
204
+ sortedBlocks: [],
205
+ sourceIds: new Set(),
206
+ validResults: [],
207
+ baseInsertIndex: -1,
208
+ aborted: true,
209
+ };
210
+ }
211
+
212
+ const baseInsertIndex = edge === 'top' ? liveTargetIndex : liveTargetIndex + 1;
213
+
118
214
  const validResults = saveResults.filter(
119
215
  (result): result is NonNullable<typeof result> => result !== null
120
216
  );
121
217
 
122
- if (validResults.length === 0) {
123
- return { duplicatedBlocks: [], targetIndex: baseInsertIndex };
218
+ return {
219
+ sortedBlocks,
220
+ sourceIds: new Set(sortedBlocks.map((b) => b.id)),
221
+ validResults,
222
+ baseInsertIndex,
223
+ aborted: false,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Sync phase of alt-drag duplicate. Performs the inserts and re-establishes
229
+ * internal parent-child relationships among the duplicated set. Every Yjs
230
+ * write this method emits must stay synchronous so the caller can bracket
231
+ * the whole thing in `BlockManager.transactForTool` for a single undo entry.
232
+ */
233
+ applyDuplicates(prep: DuplicatePreparation): DuplicateResult {
234
+ if (prep.aborted) {
235
+ return { duplicatedBlocks: [], targetIndex: prep.baseInsertIndex };
236
+ }
237
+
238
+ if (prep.validResults.length === 0) {
239
+ return { duplicatedBlocks: [], targetIndex: prep.baseInsertIndex };
124
240
  }
125
241
 
126
242
  // Insert duplicated blocks
127
- const duplicatedBlocks = validResults.map(({ saved, toolName }, index) =>
243
+ const duplicatedBlocks = prep.validResults.map(({ saved, toolName }, index) =>
128
244
  this.blockManager.insert({
129
245
  tool: toolName,
130
246
  data: saved.data,
131
247
  tunes: saved.tunes,
132
- index: baseInsertIndex + index,
248
+ index: prep.baseInsertIndex + index,
133
249
  needToFocus: false,
134
250
  })
135
251
  );
@@ -140,17 +256,16 @@ export class DragOperations {
140
256
  // corresponding duplicate parent rather than inheriting the drop context.
141
257
  if (this.blockManager.setBlockParent !== undefined) {
142
258
  const originalIdToDupId = new Map<string, string>();
143
- const sourceIds = new Set(sortedBlocks.map(b => b.id));
144
259
 
145
- sortedBlocks.forEach((originalBlock, i) => {
260
+ prep.sortedBlocks.forEach((originalBlock, i) => {
146
261
  originalIdToDupId.set(originalBlock.id, duplicatedBlocks[i].id);
147
262
  });
148
263
 
149
- sortedBlocks.forEach((originalBlock, i) => {
264
+ prep.sortedBlocks.forEach((originalBlock, i) => {
150
265
  const originalParentId = originalBlock.parentId;
151
266
 
152
267
  // Only reparent if the original parent is also part of the duplicated set
153
- if (originalParentId !== null && sourceIds.has(originalParentId)) {
268
+ if (originalParentId !== null && prep.sourceIds.has(originalParentId)) {
154
269
  const dupParentId = originalIdToDupId.get(originalParentId);
155
270
 
156
271
  if (dupParentId !== undefined && this.blockManager.setBlockParent !== undefined) {
@@ -169,7 +284,25 @@ export class DragOperations {
169
284
  });
170
285
  }
171
286
 
172
- return { duplicatedBlocks, targetIndex: baseInsertIndex };
287
+ return { duplicatedBlocks, targetIndex: prep.baseInsertIndex };
288
+ }
289
+
290
+ /**
291
+ * Duplicates blocks at a new position.
292
+ *
293
+ * Thin compatibility wrapper around {@link prepareDuplicates} and
294
+ * {@link applyDuplicates} — new call sites (notably `DragController.handleDuplicate`)
295
+ * should call those directly so the sync tail can be wrapped in
296
+ * `BlockManager.transactForTool` for single-undo semantics.
297
+ */
298
+ async duplicateBlocks(
299
+ sourceBlocks: Block[],
300
+ targetBlock: Block,
301
+ edge: 'top' | 'bottom'
302
+ ): Promise<DuplicateResult> {
303
+ const prep = await this.prepareDuplicates(sourceBlocks, targetBlock, edge);
304
+
305
+ return this.applyDuplicates(prep);
173
306
  }
174
307
 
175
308
  /**