@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.
- package/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-oWXfRfnM.mjs → blok-DbRn9adY.mjs} +2681 -2238
- package/dist/chunks/{constants-BQ1-lyZI.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{core-C942GvJO.mjs → core-B7mxBIHA.mjs} +1 -1
- package/dist/chunks/{engine-javascript-Dd6ViPCH.mjs → engine-javascript-Bmmg8uL9.mjs} +1 -1
- package/dist/chunks/{i18next-loader-CIXsptng.mjs → i18next-loader-453gJdot.mjs} +1 -1
- package/dist/chunks/{tools-MuBQQyZ-.mjs → tools-D0W3_dlA.mjs} +504 -499
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +3 -3
- package/dist/tools.mjs +2 -2
- package/package.json +3 -6
- package/src/components/block/index.ts +36 -0
- package/src/components/blocks.ts +191 -5
- package/src/components/modules/api/blocks.ts +20 -5
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
- package/src/components/modules/blockManager/blockManager.ts +364 -23
- package/src/components/modules/blockManager/hierarchy.ts +164 -8
- package/src/components/modules/blockManager/operations.ts +223 -26
- package/src/components/modules/blockManager/types.ts +13 -1
- package/src/components/modules/blockManager/yjs-sync.ts +48 -3
- package/src/components/modules/drag/DragController.ts +209 -8
- package/src/components/modules/drag/operations/DragOperations.ts +153 -20
- package/src/components/modules/paste/handlers/base.ts +48 -20
- package/src/components/modules/paste/handlers/blok-data-handler.ts +184 -44
- package/src/components/modules/paste/index.ts +20 -0
- package/src/components/modules/renderer.ts +9 -1
- package/src/components/modules/saver.ts +75 -5
- package/src/components/modules/toolbar/index.ts +41 -60
- package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
- package/src/components/modules/yjs/block-observer.ts +87 -23
- package/src/components/modules/yjs/document-store.ts +37 -11
- package/src/components/modules/yjs/index.ts +83 -7
- package/src/components/modules/yjs/types.ts +35 -2
- package/src/components/modules/yjs/undo-history.ts +116 -5
- package/src/components/utils/data-model-transform.ts +247 -35
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- package/src/markdown/markdown-handler.ts +9 -2
- package/src/styles/main.css +5 -0
- package/src/tools/callout/constants.ts +0 -1
- package/src/tools/callout/dom-builder.ts +1 -11
- package/src/tools/callout/index.ts +0 -6
- package/src/tools/header/index.ts +14 -1
- package/src/tools/table/table-operations.ts +9 -4
- package/src/tools/toggle/constants.ts +2 -1
- package/src/tools/toggle/dom-builder.ts +7 -0
- package/src/tools/toggle/index.ts +14 -1
- package/src/tools/toggle/toggle-lifecycle.ts +24 -0
- /package/dist/chunks/{lightweight-i18n-DTYoSr_o.mjs → lightweight-i18n-DSjG0iTr.mjs} +0 -0
- /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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* @
|
|
86
|
-
*
|
|
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
|
|
129
|
+
async prepareDuplicates(
|
|
89
130
|
sourceBlocks: Block[],
|
|
90
131
|
targetBlock: Block,
|
|
91
132
|
edge: 'top' | 'bottom'
|
|
92
|
-
): Promise<
|
|
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
|
-
|
|
123
|
-
|
|
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
|
/**
|