@jackuait/blok 0.10.8 → 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 (41) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
  3. package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +3 -6
  9. package/src/components/block/index.ts +36 -0
  10. package/src/components/blocks.ts +191 -5
  11. package/src/components/modules/api/blocks.ts +6 -4
  12. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  13. package/src/components/modules/blockManager/blockManager.ts +364 -23
  14. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  15. package/src/components/modules/blockManager/operations.ts +223 -26
  16. package/src/components/modules/blockManager/types.ts +13 -1
  17. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  18. package/src/components/modules/drag/DragController.ts +209 -8
  19. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  20. package/src/components/modules/paste/handlers/base.ts +48 -20
  21. package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
  22. package/src/components/modules/paste/index.ts +20 -0
  23. package/src/components/modules/saver.ts +75 -5
  24. package/src/components/modules/toolbar/index.ts +41 -60
  25. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  26. package/src/components/modules/yjs/block-observer.ts +87 -23
  27. package/src/components/modules/yjs/document-store.ts +37 -11
  28. package/src/components/modules/yjs/index.ts +83 -7
  29. package/src/components/modules/yjs/types.ts +35 -2
  30. package/src/components/modules/yjs/undo-history.ts +116 -5
  31. package/src/components/utils/data-model-transform.ts +81 -7
  32. package/src/components/utils/hierarchy-invariant.ts +137 -0
  33. package/src/styles/main.css +5 -0
  34. package/src/tools/callout/constants.ts +0 -1
  35. package/src/tools/callout/dom-builder.ts +1 -11
  36. package/src/tools/callout/index.ts +0 -6
  37. package/src/tools/header/index.ts +14 -1
  38. package/src/tools/toggle/constants.ts +2 -1
  39. package/src/tools/toggle/dom-builder.ts +7 -0
  40. package/src/tools/toggle/index.ts +14 -1
  41. package/src/tools/toggle/toggle-lifecycle.ts +24 -0
@@ -1,9 +1,11 @@
1
1
  import * as Y from 'yjs';
2
2
 
3
- import type {
4
- BlockChangeEvent,
5
- BlockChangeCallback,
6
- TransactionOrigin,
3
+ import {
4
+ LOCAL_ORIGIN_TAGS,
5
+ type BlockChangeEvent,
6
+ type BlockChangeCallback,
7
+ type LocalOriginTag,
8
+ type TransactionOrigin,
7
9
  } from './types';
8
10
 
9
11
  /**
@@ -66,34 +68,64 @@ export class BlockObserver {
66
68
 
67
69
  /**
68
70
  * Map transaction origin to event origin.
71
+ *
72
+ * Input shapes:
73
+ * - `Y.UndoManager` instance → `'undo'` or `'redo'`
74
+ * - `LocalOriginTag` string → mapped by the exhaustive switch below
75
+ * - anything else → `'remote'` (treated as a peer update)
76
+ *
77
+ * IMPORTANT: the switch is exhaustive over `LOCAL_ORIGIN_TAGS`. Adding a
78
+ * new tag there without teaching this switch is a compile error via the
79
+ * `satisfies never` guard, and the enumeration test in
80
+ * `block-observer.test.ts` catches any runtime drift. Do not add a local
81
+ * origin tag that silently falls through to `'remote'` — that is the
82
+ * exact bug class that broke `ensureCellHasBlock` → table row deletion.
69
83
  */
70
84
  public mapTransactionOrigin(origin: unknown): TransactionOrigin {
71
- if (origin === 'local') {
72
- return 'local';
73
- }
74
-
75
- if (origin === 'load') {
76
- return 'load';
77
- }
78
-
79
85
  if (this.undoManager && origin === this.undoManager) {
80
86
  return this.undoManager.undoing ? 'undo' : 'redo';
81
87
  }
82
88
 
83
- // Handle custom move origins for our application-level move undo/redo
84
- if (origin === 'move') {
85
- return 'local';
89
+ if (!this.isLocalOriginTag(origin)) {
90
+ return 'remote';
86
91
  }
87
92
 
88
- if (origin === 'move-undo') {
89
- return 'undo';
90
- }
91
-
92
- if (origin === 'move-redo') {
93
- return 'redo';
93
+ switch (origin) {
94
+ case 'local':
95
+ return 'local';
96
+ case 'load':
97
+ return 'load';
98
+ // `no-capture` is used by `DocumentStore.transactWithoutCapture` for
99
+ // local writes that must bypass the undo stack (auto-repair inserts,
100
+ // drag-move parent rewrites replayed by undo/redo, etc). They are
101
+ // LOCAL authoring writes — mapping them to `'remote'` would make
102
+ // `BlockYjsSync` call `setData(staleYjsData)` on the authoring block
103
+ // mid-operation and wipe any in-memory state the tool had written
104
+ // ahead of Yjs (e.g. Table's local model after `model.addRow()`).
105
+ case 'no-capture':
106
+ return 'local';
107
+ case 'move':
108
+ return 'local';
109
+ case 'move-undo':
110
+ return 'undo';
111
+ case 'move-redo':
112
+ return 'redo';
113
+ default: {
114
+ const _exhaustive: never = origin;
115
+
116
+ return _exhaustive;
117
+ }
94
118
  }
119
+ }
95
120
 
96
- return 'remote';
121
+ /**
122
+ * Type guard for known local-authored origin tags.
123
+ */
124
+ private isLocalOriginTag(value: unknown): value is LocalOriginTag {
125
+ return (
126
+ typeof value === 'string' &&
127
+ (LOCAL_ORIGIN_TAGS as readonly string[]).includes(value)
128
+ );
97
129
  }
98
130
 
99
131
  /**
@@ -210,13 +242,33 @@ export class BlockObserver {
210
242
  }
211
243
 
212
244
  /**
213
- * Handle map-level changes (data update).
245
+ * Handle map-level changes (data update, tunes update, or top-level
246
+ * yblock key changes like `parentId` / `contentIds`).
247
+ *
248
+ * When a remote client reparents a block, the changed Y.Map is the
249
+ * yblock itself — not a nested `data`/`tunes` sub-map. Detect both
250
+ * cases so we always emit an update event for the affected block id.
214
251
  */
215
252
  private handleMapEvent(ymap: Y.Map<unknown>, origin: TransactionOrigin): void {
216
253
  if (this.yblocks === null) {
217
254
  return;
218
255
  }
219
256
 
257
+ // Direct yblock change (e.g. parentId/contentIds written on the yblock itself).
258
+ if (this.isTopLevelYblock(ymap)) {
259
+ const id: unknown = ymap.get('id');
260
+
261
+ if (typeof id === 'string') {
262
+ this.emitChange({
263
+ type: 'update',
264
+ blockId: id,
265
+ origin,
266
+ });
267
+ }
268
+
269
+ return;
270
+ }
271
+
220
272
  const yblock = this.findParentBlock(ymap);
221
273
 
222
274
  if (yblock === undefined) {
@@ -230,6 +282,18 @@ export class BlockObserver {
230
282
  });
231
283
  }
232
284
 
285
+ /**
286
+ * Returns true if the given Y.Map is one of the top-level yblocks tracked
287
+ * in the blocks array.
288
+ */
289
+ private isTopLevelYblock(ymap: Y.Map<unknown>): boolean {
290
+ if (this.yblocks === null) {
291
+ return false;
292
+ }
293
+
294
+ return this.yblocks.toArray().includes(ymap);
295
+ }
296
+
233
297
  /**
234
298
  * Find the parent block Y.Map for a nested Y.Map (data or tunes).
235
299
  */
@@ -1,7 +1,7 @@
1
1
  import * as Y from 'yjs';
2
2
 
3
3
  import type { YBlockSerializer, YjsOutputBlockData } from './serializer';
4
- import type { TransactionOrigin } from './types';
4
+ import type { LocalOriginTag } from './types';
5
5
  import { equals } from '../../utils/object';
6
6
 
7
7
  // Re-export YjsOutputBlockData as DocumentStoreBlockData for consistency
@@ -17,9 +17,15 @@ type DocumentStoreBlockData = YjsOutputBlockData;
17
17
  */
18
18
  export class DocumentStore {
19
19
  /**
20
- * Yjs document instance
20
+ * Yjs document instance.
21
+ *
22
+ * PRIVATE by design: all writes MUST route through `transact` or
23
+ * `transactWithoutCapture` so the origin passes the `LocalOriginTag`
24
+ * type barrier. Exposing the raw Y.Doc lets callers bypass the
25
+ * whitelist and silently reintroduce the class of bugs that
26
+ * `BlockObserver.mapTransactionOrigin` exists to prevent.
21
27
  */
22
- public readonly ydoc: Y.Doc = new Y.Doc();
28
+ private readonly ydoc: Y.Doc = new Y.Doc();
23
29
 
24
30
  /**
25
31
  * Yjs array containing all blocks
@@ -97,7 +103,11 @@ export class DocumentStore {
97
103
  * @param toIndex - Target index (the final position where the block should end up)
98
104
  * @param origin - Transaction origin
99
105
  */
100
- public moveBlock(id: string, toIndex: number, origin: TransactionOrigin): void {
106
+ public moveBlock(
107
+ id: string,
108
+ toIndex: number,
109
+ origin: 'local' | 'move-undo' | 'move-redo'
110
+ ): void {
101
111
  const fromIndex = this.findBlockIndex(id);
102
112
 
103
113
  if (fromIndex === -1) {
@@ -112,7 +122,7 @@ export class DocumentStore {
112
122
  // Use the origin for the transaction:
113
123
  // - 'local' for user-initiated moves (we use 'move' so Yjs UndoManager doesn't track them)
114
124
  // - 'move-undo' / 'move-redo' for our custom undo/redo (maps to 'undo'/'redo' for DOM sync)
115
- const transactionOrigin = origin === 'local' ? 'move' : origin;
125
+ const transactionOrigin: LocalOriginTag = origin === 'local' ? 'move' : origin;
116
126
 
117
127
  this.transact(() => {
118
128
  const yblock = this.yblocks.get(fromIndex);
@@ -150,12 +160,14 @@ export class DocumentStore {
150
160
  * @param id - Block id
151
161
  * @param key - Data property key
152
162
  * @param value - New value
163
+ * @returns true if a Yjs write actually occurred (value changed), false if the
164
+ * equality guard short-circuited the write.
153
165
  */
154
- public updateBlockData(id: string, key: string, value: unknown): void {
166
+ public updateBlockData(id: string, key: string, value: unknown): boolean {
155
167
  const yblock = this.getBlockById(id);
156
168
 
157
169
  if (yblock === undefined) {
158
- return;
170
+ return false;
159
171
  }
160
172
 
161
173
  const ydata = yblock.get('data') as Y.Map<unknown>;
@@ -166,12 +178,14 @@ export class DocumentStore {
166
178
  // (e.g., marker updates in list items during undo/redo, or table content
167
179
  // arrays that are reference-different but structurally identical)
168
180
  if (equals(currentValue, value)) {
169
- return;
181
+ return false;
170
182
  }
171
183
 
172
184
  this.transact(() => {
173
185
  ydata.set(key, value);
174
186
  }, 'local');
187
+
188
+ return true;
175
189
  }
176
190
 
177
191
  /**
@@ -199,11 +213,21 @@ export class DocumentStore {
199
213
  * @param lastEditedAt - Timestamp in milliseconds
200
214
  * @param lastEditedBy - User ID, or null
201
215
  */
202
- public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): void {
216
+ public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): boolean {
203
217
  const yblock = this.getBlockById(id);
204
218
 
205
219
  if (yblock === undefined) {
206
- return;
220
+ return false;
221
+ }
222
+
223
+ // Defensive equality guard — if both fields already match, skip the write to avoid
224
+ // adding an empty/no-op entry to the Yjs undo stack.
225
+ const currentEditedAt = yblock.get('lastEditedAt');
226
+ const currentEditedBy = yblock.get('lastEditedBy');
227
+ const editedByMatches = lastEditedBy === null || currentEditedBy === lastEditedBy;
228
+
229
+ if (currentEditedAt === lastEditedAt && editedByMatches) {
230
+ return false;
207
231
  }
208
232
 
209
233
  this.transact(() => {
@@ -213,6 +237,8 @@ export class DocumentStore {
213
237
  yblock.set('lastEditedBy', lastEditedBy);
214
238
  }
215
239
  }, 'local');
240
+
241
+ return true;
216
242
  }
217
243
 
218
244
  /**
@@ -230,7 +256,7 @@ export class DocumentStore {
230
256
  * @param fn - Function containing Yjs operations to execute atomically
231
257
  * @param origin - Transaction origin
232
258
  */
233
- public transact(fn: () => void, origin: TransactionOrigin): void {
259
+ public transact(fn: () => void, origin: LocalOriginTag): void {
234
260
  this.ydoc.transact(fn, origin);
235
261
  }
236
262
 
@@ -43,10 +43,23 @@ export class YjsManager extends Module {
43
43
  private blockObserver: BlockObserver;
44
44
 
45
45
  /**
46
- * Flag to track if move group is active
46
+ * Flag to track if move group is active.
47
+ *
48
+ * Read via the `isInMoveGroup` getter by `BlockManager.setBlockParent`,
49
+ * which routes its Yjs writes through `transactWithoutCapture` while this
50
+ * flag is true so the parent change attaches to the in-flight move entry
51
+ * instead of landing on Y.UndoManager as a separate stack item.
47
52
  */
48
53
  private isMoveGroupActive = false;
49
54
 
55
+ /**
56
+ * Whether a drag-backed move group is currently open.
57
+ * See `isMoveGroupActive`.
58
+ */
59
+ public get isInMoveGroup(): boolean {
60
+ return this.isMoveGroupActive;
61
+ }
62
+
50
63
  /**
51
64
  * Constructor - initializes all components
52
65
  */
@@ -67,6 +80,42 @@ export class YjsManager extends Module {
67
80
  this.documentStore.moveBlock(blockId, toIndex, origin);
68
81
  });
69
82
 
83
+ // Set up parent-restore callback — invoked by UndoHistory during
84
+ // move-undo/move-redo on drag-reparent entries.
85
+ //
86
+ // Writes parentId (and the two parents' contentIds) to Yjs under
87
+ // `transactWithoutCapture` so Y.UndoManager does not record the replay
88
+ // as a new stack item, then drives the in-memory BlockManager reparent
89
+ // directly via `reparentFromHistoryReplay`. Going direct avoids the
90
+ // `handleYjsUpdate` path's parentId-delete blind spot (that handler
91
+ // gates reconciliation on `yblock.has('parentId')` which is false after
92
+ // a delete, so a non-root → root undo would otherwise silently skip).
93
+ this.undoHistory.setParentRestoreCallback((blockId, parentId) => {
94
+ const yblock = this.documentStore.getBlockById(blockId);
95
+
96
+ if (yblock === undefined) {
97
+ return;
98
+ }
99
+
100
+ this.documentStore.transactWithoutCapture(() => {
101
+ if (parentId !== null) {
102
+ yblock.set('parentId', parentId);
103
+ } else {
104
+ yblock.delete('parentId');
105
+ }
106
+ });
107
+
108
+ const blockManager = this.Blok?.BlockManager;
109
+
110
+ if (blockManager !== undefined) {
111
+ const block = blockManager.getBlockById(blockId);
112
+
113
+ if (block !== undefined) {
114
+ blockManager.reparentFromHistoryReplay(block, parentId);
115
+ }
116
+ }
117
+ });
118
+
70
119
  // Set up observation
71
120
  this.blockObserver.observe(this.documentStore.yblocks, this.undoHistory.undoManager);
72
121
  }
@@ -148,10 +197,10 @@ export class YjsManager extends Module {
148
197
  * @param key - Data property key
149
198
  * @param value - New value
150
199
  */
151
- public updateBlockData(id: string, key: string, value: unknown): void {
200
+ public updateBlockData(id: string, key: string, value: unknown): boolean {
152
201
  this.undoHistory.markCaretBeforeChange();
153
202
 
154
- this.documentStore.updateBlockData(id, key, value);
203
+ return this.documentStore.updateBlockData(id, key, value);
155
204
  }
156
205
 
157
206
  /**
@@ -170,8 +219,8 @@ export class YjsManager extends Module {
170
219
  * @param lastEditedAt - Timestamp in milliseconds
171
220
  * @param lastEditedBy - User ID, or null
172
221
  */
173
- public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): void {
174
- this.documentStore.updateBlockMetadata(id, lastEditedAt, lastEditedBy);
222
+ public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): boolean {
223
+ return this.documentStore.updateBlockMetadata(id, lastEditedAt, lastEditedBy);
175
224
  }
176
225
 
177
226
  /**
@@ -259,8 +308,35 @@ export class YjsManager extends Module {
259
308
  */
260
309
  public transactMoves(fn: () => void): void {
261
310
  this.isMoveGroupActive = true;
262
- this.undoHistory.transactMoves(fn);
263
- this.isMoveGroupActive = false;
311
+ try {
312
+ this.undoHistory.transactMoves(fn);
313
+ } finally {
314
+ this.isMoveGroupActive = false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Attach a parent change to the in-flight move entry so `undo`/`redo`
320
+ * restores the block's parent atomically with its position.
321
+ *
322
+ * Called from `BlockManager.setBlockParent` when a drag-backed move group
323
+ * is open (see `isInMoveGroup`). The accompanying Yjs parentId write must
324
+ * use `transactWithoutCapture` — otherwise Y.UndoManager records it as a
325
+ * separate stack item and the drag splits into a two-step undo.
326
+ * @param blockId - id of the block being reparented
327
+ * @param fromParentId - parent id before the reparent (null for root)
328
+ * @param toParentId - parent id after the reparent (null for root)
329
+ */
330
+ public recordParentChangeForPendingMove(
331
+ blockId: string,
332
+ fromParentId: string | null,
333
+ toParentId: string | null
334
+ ): void {
335
+ this.undoHistory.recordParentChangeForPendingMove(
336
+ blockId,
337
+ fromParentId,
338
+ toParentId
339
+ );
264
340
  }
265
341
 
266
342
  /**
@@ -10,8 +10,9 @@ export type BlockChangeEvent =
10
10
  | { type: 'batch-add'; blockIds: string[]; origin: TransactionOrigin };
11
11
 
12
12
  /**
13
- * Transaction origin types.
14
- * Includes 'move' and 'move-undo'/'move-redo' for custom move handling.
13
+ * Transaction origin types AFTER classification by
14
+ * `BlockObserver.mapTransactionOrigin`. This is what downstream consumers
15
+ * (e.g. `BlockYjsSync`) see on a `BlockChangeEvent`.
15
16
  */
16
17
  export type TransactionOrigin =
17
18
  | 'local'
@@ -23,6 +24,30 @@ export type TransactionOrigin =
23
24
  | 'move-undo'
24
25
  | 'move-redo';
25
26
 
27
+ /**
28
+ * Whitelist of raw origin tags that our own code passes to `Y.Doc.transact`.
29
+ *
30
+ * Adding a new local-authored origin tag? You MUST:
31
+ * 1. Add it here.
32
+ * 2. Handle it explicitly in `BlockObserver.mapTransactionOrigin`.
33
+ *
34
+ * The mapper's exhaustiveness check and the `block-observer.test.ts`
35
+ * enumeration test will otherwise fail CI — preventing a repeat of the
36
+ * table-row-removal bug where `'no-capture'` silently fell through to
37
+ * `'remote'` and made `BlockYjsSync` clobber the authoring tool's state
38
+ * with stale Yjs data mid-operation.
39
+ */
40
+ export const LOCAL_ORIGIN_TAGS = [
41
+ 'local',
42
+ 'load',
43
+ 'no-capture',
44
+ 'move',
45
+ 'move-undo',
46
+ 'move-redo',
47
+ ] as const;
48
+
49
+ export type LocalOriginTag = (typeof LOCAL_ORIGIN_TAGS)[number];
50
+
26
51
  /**
27
52
  * Callback for block change events
28
53
  */
@@ -47,11 +72,19 @@ export interface CaretHistoryEntry {
47
72
 
48
73
  /**
49
74
  * Represents a single move operation within a move group.
75
+ *
76
+ * Drag-reparent flows attach `fromParentId`/`toParentId` so that undo/redo
77
+ * can restore the parent relationship atomically alongside the array move.
78
+ * Without this, a drag-reparent splits across two history stacks
79
+ * (`moveUndoStack` for the array move, Y.UndoManager for the parentId write)
80
+ * and requires two Cmd+Z presses to fully reverse.
50
81
  */
51
82
  export interface SingleMoveEntry {
52
83
  blockId: string;
53
84
  fromIndex: number;
54
85
  toIndex: number;
86
+ fromParentId?: string | null;
87
+ toParentId?: string | null;
55
88
  }
56
89
 
57
90
  /**
@@ -102,6 +102,15 @@ export class UndoHistory {
102
102
  */
103
103
  private moveCallback: (blockId: string, toIndex: number, origin: 'local' | 'move-undo' | 'move-redo') => void;
104
104
 
105
+ /**
106
+ * Callback to restore a block's parent during move-undo/move-redo.
107
+ *
108
+ * Must not record its own history entry — the call is part of replaying
109
+ * an existing `SingleMoveEntry`. Set by YjsManager to route through
110
+ * `transactWithoutCapture` + a direct in-memory reparent.
111
+ */
112
+ private parentRestoreCallback: (blockId: string, parentId: string | null) => void;
113
+
105
114
  constructor(
106
115
  yblocks: Y.Array<Y.Map<unknown>>,
107
116
  blok: BlokModules
@@ -120,6 +129,9 @@ export class UndoHistory {
120
129
  this.moveCallback = () => {
121
130
  // Placeholder, will be set by setMoveCallback
122
131
  };
132
+ this.parentRestoreCallback = () => {
133
+ // Placeholder, will be set by setParentRestoreCallback
134
+ };
123
135
  }
124
136
 
125
137
  /**
@@ -131,6 +143,16 @@ export class UndoHistory {
131
143
  this.moveCallback = callback;
132
144
  }
133
145
 
146
+ /**
147
+ * Set the parent-restore callback used by move-undo/move-redo to rewind
148
+ * drag-reparent side effects. See `parentRestoreCallback`.
149
+ */
150
+ public setParentRestoreCallback(
151
+ callback: (blockId: string, parentId: string | null) => void
152
+ ): void {
153
+ this.parentRestoreCallback = callback;
154
+ }
155
+
134
156
  /**
135
157
  * Set the Blok modules. Called when Blok modules are initialized.
136
158
  */
@@ -216,10 +238,14 @@ export class UndoHistory {
216
238
  // Push to redo stack for potential redo
217
239
  this.moveRedoStack.push(lastMoveGroup);
218
240
 
219
- // Reverse all moves in the group, in reverse order
220
- // This is crucial for multi-block moves to restore correctly
241
+ // Reverse all moves in the group, in reverse order.
242
+ // This is crucial for multi-block moves to restore correctly.
243
+ //
244
+ // Drag-reparent entries may additionally carry `fromParentId`; restore
245
+ // the parent BEFORE the position so the block lands in the correct
246
+ // flat-array slot relative to its (soon-to-be-restored) parent siblings.
221
247
  [...lastMoveGroup].reverse().forEach((move) => {
222
- this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
248
+ this.replayMoveUndo(move);
223
249
  });
224
250
 
225
251
  // Pop caret entry only after move succeeds
@@ -257,9 +283,12 @@ export class UndoHistory {
257
283
  // Push back to undo stack
258
284
  this.moveUndoStack.push(lastMoveGroup);
259
285
 
260
- // Redo all moves in the group, in original order
286
+ // Redo all moves in the group, in original order. Drag-reparent
287
+ // entries restore the destination parent AFTER the position so that
288
+ // the flat-array splice settles first and the parent's contentIds
289
+ // then re-attach cleanly.
261
290
  for (const move of lastMoveGroup) {
262
- this.moveCallback(move.blockId, move.toIndex, 'move-redo');
291
+ this.replayMoveRedo(move);
263
292
  }
264
293
 
265
294
  // Pop caret entry only after move succeeds
@@ -307,6 +336,36 @@ export class UndoHistory {
307
336
  this.restoreCaretSnapshot(snapshot);
308
337
  }
309
338
 
339
+ /**
340
+ * Replay a single move entry in the undo direction.
341
+ * Parent restore runs BEFORE the position restore so the block lands in
342
+ * the correct slot relative to its (soon-to-be-restored) parent siblings.
343
+ */
344
+ private replayMoveUndo(move: SingleMoveEntry): void {
345
+ if (move.fromParentId !== undefined) {
346
+ this.parentRestoreCallback(move.blockId, move.fromParentId);
347
+ }
348
+
349
+ if (move.fromIndex !== -1) {
350
+ this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Replay a single move entry in the redo direction.
356
+ * Position restore runs BEFORE the parent restore so the flat-array splice
357
+ * settles first and the destination parent's contentIds re-attach cleanly.
358
+ */
359
+ private replayMoveRedo(move: SingleMoveEntry): void {
360
+ if (move.toIndex !== -1) {
361
+ this.moveCallback(move.blockId, move.toIndex, 'move-redo');
362
+ }
363
+
364
+ if (move.toParentId !== undefined) {
365
+ this.parentRestoreCallback(move.blockId, move.toParentId);
366
+ }
367
+ }
368
+
310
369
  /**
311
370
  * Execute a Yjs UndoManager operation with the isPerformingUndoRedo flag set.
312
371
  * This prevents the stack-item-added listener from modifying caret stacks during
@@ -438,6 +497,58 @@ export class UndoHistory {
438
497
  }
439
498
  }
440
499
 
500
+ /**
501
+ * Attach a parent change to the in-flight move entry (or create a
502
+ * parent-only entry if the block hasn't been moved inside the group yet).
503
+ *
504
+ * Used by drag-reparent so that `undo` restores the parent relationship
505
+ * atomically with the array move. The caller (`BlockManager.setBlockParent`
506
+ * when `YjsManager.isInMoveGroup` is true) is responsible for writing the
507
+ * parentId/contentIds to Yjs through `transactWithoutCapture` so the
508
+ * Y.UndoManager does not also record the change.
509
+ * @param blockId - id of the reparented block
510
+ * @param fromParentId - parent id before the reparent (null for root)
511
+ * @param toParentId - parent id after the reparent (null for root)
512
+ */
513
+ public recordParentChangeForPendingMove(
514
+ blockId: string,
515
+ fromParentId: string | null,
516
+ toParentId: string | null
517
+ ): void {
518
+ if (this.pendingMoveGroup === null) {
519
+ // Not inside a move group — nothing to attach to. Drop the hint.
520
+ return;
521
+ }
522
+
523
+ const existing = this.pendingMoveGroup.find(
524
+ entry => entry.blockId === blockId
525
+ );
526
+
527
+ if (existing !== undefined) {
528
+ // Preserve the earliest known `fromParentId` (first write wins — that's
529
+ // the parent BEFORE the drag started). Always update `toParentId` to
530
+ // the most recent write.
531
+ if (existing.fromParentId === undefined) {
532
+ existing.fromParentId = fromParentId;
533
+ }
534
+ existing.toParentId = toParentId;
535
+
536
+ return;
537
+ }
538
+
539
+ // No matching move entry yet (e.g. a same-index reparent within a toggle
540
+ // body, where DragController calls setBlockParent without a prior move).
541
+ // Push a parent-only entry with identical from/to indices so the undo
542
+ // walker still has something to unwind.
543
+ this.pendingMoveGroup.push({
544
+ blockId,
545
+ fromIndex: -1,
546
+ toIndex: -1,
547
+ fromParentId,
548
+ toParentId,
549
+ });
550
+ }
551
+
441
552
  /**
442
553
  * Capture the current caret position as a snapshot.
443
554
  * @returns CaretSnapshot or null if no block is focused