@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
@@ -441,20 +441,24 @@ export class Toolbar extends Module<ToolbarNodes> {
441
441
 
442
442
  /**
443
443
  * Adjust toolbar button visibility based on context:
444
- * - Table cell focus: settings toggler hidden (drag/settings don't apply to cells)
445
444
  * - Callout first child: both plus button and settings toggler hidden
446
445
  * to prevent overlap with the callout's emoji icon
446
+ *
447
+ * The callout block itself still shows BOTH buttons — the actions container
448
+ * sits outside the block (positioned via right:100% on the left gutter) and
449
+ * does not overlap the emoji which is inside the block at pl-8.
450
+ *
451
+ * Note: when the toolbar resolves to a parent table block from a focused
452
+ * cell, the settings toggler must STAY visible — it is wired via
453
+ * setupDraggable() to drag the parent table, so hiding it would leave the
454
+ * whole table undraggable while the user edits cell text.
447
455
  */
448
- const focusIsInsideCell = this.isFocusInsideTableCell();
449
456
  const isCalloutFirstChild = this.isFirstChildOfCallout(targetBlock);
450
- const isCalloutBlock = targetBlock.name === 'callout';
451
457
 
452
- // Hide plus button for callout blocks and their first children to avoid
453
- // overlap with the callout emoji icon in the left padding area.
454
- plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
458
+ plusButton.style.display = isCalloutFirstChild ? 'none' : '';
455
459
 
456
460
  if (settingsToggler) {
457
- settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
461
+ settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
458
462
  }
459
463
 
460
464
  /**
@@ -466,7 +470,8 @@ export class Toolbar extends Module<ToolbarNodes> {
466
470
  * Skip when the target is the callout itself or its first child — their toolbar
467
471
  * buttons render outside the callout's visual background area.
468
472
  */
469
- const calloutBg = isCalloutFirstChild || targetBlock.name === 'callout'
473
+ const isCalloutBlock = targetBlock.name === 'callout';
474
+ const calloutBg = isCalloutFirstChild || isCalloutBlock
470
475
  ? null
471
476
  : this.getCalloutBackgroundColor(targetBlock);
472
477
 
@@ -875,70 +880,48 @@ export class Toolbar extends Module<ToolbarNodes> {
875
880
  return parent;
876
881
  }
877
882
 
878
- private isFocusInsideTableCell(): boolean {
879
- const active = document.activeElement;
880
-
881
- if (!active) {
882
- return false;
883
- }
884
-
885
- return active.closest('[data-blok-table-cell-blocks]') !== null;
886
- }
887
-
888
883
  /**
889
- * Updates toolbar button visibility based on whether a table cell has focus.
890
- * The plus button always stays visible; the settings toggler is hidden when
891
- * focus is inside a cell and restored when focus moves to a regular block.
884
+ * Updates toolbar button visibility based on the current hovered block.
885
+ * Called from the focusin listener so callout first-child state is
886
+ * refreshed immediately when focus moves, without waiting for the next
887
+ * hover/moveAndOpen cycle.
892
888
  *
893
- * Called from the focusin listener so button state is updated immediately
894
- * on click, without waiting for the next hover/moveAndOpen cycle.
889
+ * INVARIANT: the ONLY reason this method may hide the settings toggler
890
+ * (drag handle) is `isCalloutFirstChild` a structural property of the
891
+ * block, NOT where `document.activeElement` currently is. Hiding the drag
892
+ * handle based on focus position inside nested content (table cells,
893
+ * code editors, database titles, etc.) will break dragging of the
894
+ * containing block while the user edits its content. Do NOT add focus-
895
+ * based or DOM-attribute-based hide branches here. See the arch guard
896
+ * test in test/unit/components/modules/toolbar/index.test.ts.
895
897
  */
896
- private updateToolbarButtonsForTableCellFocus(): void {
898
+ private updateToolbarButtonsForCalloutFirstChild(): void {
897
899
  const { plusButton, settingsToggler } = this.nodes;
898
900
 
899
901
  if (!plusButton) {
900
902
  return;
901
903
  }
902
904
 
903
- const focusIsInsideCell = this.isFocusInsideTableCell();
904
905
  const isCalloutFirstChild = this.hoveredBlock !== null && this.isFirstChildOfCallout(this.hoveredBlock);
905
- const isCalloutBlock = this.hoveredBlock?.name === 'callout';
906
906
 
907
- plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
907
+ plusButton.style.display = isCalloutFirstChild ? 'none' : '';
908
908
 
909
909
  if (settingsToggler) {
910
- settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
910
+ settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
911
911
  }
912
912
  }
913
913
 
914
914
  /**
915
- * Re-enables pointer-events on the settings toggler (and callout drag zone) after
916
- * the actions container has been set to pointer-events: none for left-edge blocks.
915
+ * Re-enables pointer-events on the settings toggler after the actions
916
+ * container has been set to pointer-events: none for left-edge blocks.
917
917
  *
918
- * For callout blocks: also wires the dedicated drag zone as the drag handle and
919
- * re-enables the settings toggler so the settings menu remains accessible.
920
- * The emoji button (at x=32px) no longer overlaps the actions zone (x=[0,29px])
921
- * because the callout uses pl-8 (32px) left padding.
922
- *
923
- * For all other left-edge blocks (toggle, header with arrow): simply re-enables the
924
- * settings toggler so it continues to function as the drag handle.
918
+ * Left-edge blocks (callout, toggle, header-with-arrow) disable
919
+ * pointer-events on the actions container so clicks pass through to the
920
+ * block's own left-edge interactive element (emoji / toggle arrow). The
921
+ * settings toggler must remain clickable on top so it can still function
922
+ * as the drag handle and open the block tunes menu.
925
923
  */
926
- private restoreSettingsTogglerForLeftEdgeBlock(targetBlock: Block): void {
927
- if (targetBlock.name === 'callout') {
928
- if (this.nodes.settingsToggler) {
929
- this.nodes.settingsToggler.style.pointerEvents = 'auto';
930
- }
931
-
932
- const calloutDragZone = targetBlock.holder.querySelector<HTMLElement>('[data-callout-drag-zone]');
933
-
934
- if (calloutDragZone) {
935
- calloutDragZone.style.pointerEvents = 'auto';
936
- targetBlock.setupDraggable(calloutDragZone, this.Blok.DragManager);
937
- }
938
-
939
- return;
940
- }
941
-
924
+ private restoreSettingsTogglerForLeftEdgeBlock(_targetBlock: Block): void {
942
925
  if (this.nodes.settingsToggler) {
943
926
  this.nodes.settingsToggler.style.pointerEvents = 'auto';
944
927
  }
@@ -1264,15 +1247,13 @@ export class Toolbar extends Module<ToolbarNodes> {
1264
1247
  }
1265
1248
 
1266
1249
  /**
1267
- * Listen for focus changes inside the editor.
1268
- * When the user clicks/tabs into a table cell, hide the plus button and
1269
- * settings toggler. When focus moves to a regular block, restore them.
1270
- *
1271
- * This runs on every focusin event (not throttled) to ensure buttons
1272
- * update immediately on click — no 300ms delay.
1250
+ * Listen for focus changes inside the editor so callout first-child
1251
+ * button state refreshes immediately on click/tab no 300ms delay.
1252
+ * Drag handle visibility must NOT depend on focus position inside
1253
+ * nested content (see updateToolbarButtonsForCalloutFirstChild doc).
1273
1254
  */
1274
1255
  this.readOnlyMutableListeners.on(this.Blok.UI.nodes.wrapper, 'focusin', () => {
1275
- this.updateToolbarButtonsForTableCellFocus();
1256
+ this.updateToolbarButtonsForCalloutFirstChild();
1276
1257
  });
1277
1258
 
1278
1259
  /**
@@ -492,6 +492,26 @@ export class KeyboardController extends Controller {
492
492
  return;
493
493
  }
494
494
 
495
+ /**
496
+ * Layer 18: block undo/redo while a drag is in progress.
497
+ *
498
+ * Regression: "wrong block dropped" family. `moveUndoStack` entries capture
499
+ * `fromIndex`/`toIndex` at record time. Replaying them synchronously while
500
+ * DragController still holds a live source/target reference mutates the
501
+ * flat blocks array under the drag's feet — subsequent `handleDrop` then
502
+ * operates on stale indices and silently drops an unrelated block.
503
+ *
504
+ * The Escape handler already guards on `DragManager.isDragging` (see
505
+ * handleEscape above). Mirror that here: swallow the keystroke so the
506
+ * drag completes cleanly, then the user can undo.
507
+ */
508
+ if (this.Blok.DragManager.isDragging) {
509
+ event.preventDefault();
510
+ event.stopPropagation();
511
+
512
+ return;
513
+ }
514
+
495
515
  // Prevent double-firing within 50ms
496
516
  const now = Date.now();
497
517
 
@@ -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
  /**