@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
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { Block } from '../../block';
7
7
  import { DATA_ATTR } from '../../constants/data-attributes';
8
+ import { logLabeled } from '../../utils';
8
9
 
9
10
  import type { BlockRepository } from './repository';
10
11
 
@@ -14,28 +15,56 @@ import type { BlockRepository } from './repository';
14
15
  export class BlockHierarchy {
15
16
  private readonly repository: BlockRepository;
16
17
  private readonly onParentChanged?: (parentId: string) => void;
18
+ private readonly getIsSyncingFromYjs?: () => boolean;
17
19
 
18
20
  /**
19
21
  * @param repository - BlockRepository for looking up blocks by id
20
22
  * @param onParentChanged - optional callback invoked after a block is assigned a non-null parent
23
+ * @param getIsSyncingFromYjs - optional getter that reports whether the editor is
24
+ * currently applying a remote Yjs update. When true, the Layer 7 dangling
25
+ * parent id guard skips the throw and always coerces + logs — remote
26
+ * peers can legitimately deliver a transiently-dangling parent id during
27
+ * conflict resolution, batched undo replay, or initial sync ordering.
21
28
  */
22
- constructor(repository: BlockRepository, onParentChanged?: (parentId: string) => void) {
29
+ constructor(
30
+ repository: BlockRepository,
31
+ onParentChanged?: (parentId: string) => void,
32
+ getIsSyncingFromYjs?: () => boolean
33
+ ) {
23
34
  this.repository = repository;
24
35
  this.onParentChanged = onParentChanged;
36
+ this.getIsSyncingFromYjs = getIsSyncingFromYjs;
25
37
  }
26
38
 
27
39
  /**
28
40
  * Returns the depth (nesting level) of a block in the hierarchy.
29
41
  * Root-level blocks have depth 0.
42
+ *
43
+ * Fix 4: a `visited` set guards against malformed parent chains that form a
44
+ * cycle (e.g. remote peers that concurrently reparent A→B and B→A converge
45
+ * into A↔B). Without the guard, the recursion blows the stack and takes
46
+ * down the tab.
30
47
  * @param block - the block to get depth for
31
48
  * @returns {number} - depth level (0 for root, 1 for first level children, etc.)
32
49
  */
33
50
  public getBlockDepth(block: Block): number {
51
+ const visited = new Set<string>();
52
+
53
+ if (block.id !== undefined) {
54
+ visited.add(block.id);
55
+ }
56
+
34
57
  const calculateDepth = (parentId: string | null, currentDepth: number): number => {
35
58
  if (parentId === null) {
36
59
  return currentDepth;
37
60
  }
38
61
 
62
+ if (visited.has(parentId)) {
63
+ // Cycle detected — bail to the current depth so we don't blow the stack.
64
+ return currentDepth;
65
+ }
66
+ visited.add(parentId);
67
+
39
68
  const parentBlock = this.repository.getBlockById(parentId);
40
69
 
41
70
  if (parentBlock === undefined) {
@@ -48,12 +77,129 @@ export class BlockHierarchy {
48
77
  return calculateDepth(block.parentId, 0);
49
78
  }
50
79
 
80
+ /**
81
+ * Walks the target parent chain and returns true if `childId` already
82
+ * appears in it — meaning assigning `child` as a descendant of the target
83
+ * parent would form a cycle.
84
+ *
85
+ * Fix 4 companion guard for {@link setBlockParent}.
86
+ * @param childId - block id being reparented
87
+ * @param targetParentId - prospective new parent id
88
+ * @returns true if the assignment would form a cycle
89
+ */
90
+ private wouldFormCycle(childId: string, targetParentId: string): boolean {
91
+ const walk = (cursor: string | null, visited: Set<string>): boolean => {
92
+ if (cursor === null) {
93
+ return false;
94
+ }
95
+ if (cursor === childId) {
96
+ return true;
97
+ }
98
+ if (visited.has(cursor)) {
99
+ // Pre-existing cycle — still disqualifies the reparent.
100
+ return true;
101
+ }
102
+ visited.add(cursor);
103
+
104
+ const parent = this.repository.getBlockById(cursor);
105
+
106
+ if (parent === undefined) {
107
+ return false;
108
+ }
109
+
110
+ return walk(parent.parentId, visited);
111
+ };
112
+
113
+ return walk(targetParentId, new Set<string>());
114
+ }
115
+
51
116
  /**
52
117
  * Sets the parent of a block, updating both the block's parentId and the parent's contentIds.
53
118
  * @param block - the block to reparent
54
119
  * @param newParentId - the new parent block id, or null for root level
55
120
  */
56
121
  public setBlockParent(block: Block, newParentId: string | null): void {
122
+ /**
123
+ * Layer 19: stale-block guard (regression: wrong-block-dropped family).
124
+ *
125
+ * If `block` has been destroyed and is no longer in the repository,
126
+ * `repository.blocks.indexOf(block)` below returns -1. The toggle-DOM
127
+ * anchor logic then runs `allBlocks.slice(0, -1)` — the whole array
128
+ * minus its last element — and silently anchors the stale block's
129
+ * holder at a completely unrelated DOM position. The new-parent
130
+ * branch repeats the same failure with `slice(0)` returning every
131
+ * block. That's the DOM-manipulation analogue of the `splice(-1, …)`
132
+ * root cause behind the original "wrong block dropped" bug.
133
+ *
134
+ * Additionally, without this guard `block.parentId` would be mutated
135
+ * on a destroyed reference and `onParentChanged` would fire with a
136
+ * ghost id, polluting Yjs with writes against a dead block.
137
+ *
138
+ * Bail out cleanly at entry so callers — DragController.handleDrop in
139
+ * particular — get a no-op instead of silent DOM/data corruption.
140
+ */
141
+ if (this.repository.getBlockIndex(block) === -1) {
142
+ return;
143
+ }
144
+
145
+ /**
146
+ * Fix 4: cycle guard.
147
+ *
148
+ * Reject reparents that would form a cycle (e.g. make A a descendant of
149
+ * one of its own descendants). Without this guard, a corrupted remote
150
+ * update can land the editor in a state where getBlockDepth recurses
151
+ * forever, plus any hierarchical save would produce a tree that can
152
+ * never round-trip.
153
+ */
154
+ if (newParentId !== null && this.wouldFormCycle(block.id, newParentId)) {
155
+ throw new Error(
156
+ `BlockHierarchy.setBlockParent: refusing to form cycle — assigning ${block.id} to parent ${newParentId} would create a parent/child cycle.`
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Layer 7: universal chokepoint guard against dangling parentId.
162
+ *
163
+ * Every reparent in the editor — paste, drag, split, duplicate, slash
164
+ * menu, Cmd+D, markdown shortcut, public api — flows through this
165
+ * method. Previously, if the caller passed a parent id that was no
166
+ * longer in the repository, the write silently mutated block.parentId
167
+ * to garbage: getBlockById returned undefined, the new-parent DOM and
168
+ * contentIds branches no-opped, but `block.parentId = newParentId`
169
+ * still ran. The ghost id then survived until Saver's dangling-parent
170
+ * repair (layer 5), by which point the block has already been
171
+ * ejected from any container it was supposed to belong to.
172
+ *
173
+ * Guarding at this chokepoint catches the regression at the point of
174
+ * introduction instead of one save cycle later:
175
+ * - test/dev: throw loudly so the offending caller is fixed before
176
+ * the build ships.
177
+ * - prod: coerce to null + log `error`, matching the saver's graceful
178
+ * repair semantics so end users never see a wedged editor.
179
+ *
180
+ * This is the upstream-most defense in the callout paste ejection
181
+ * bug family (operations.paste title-vs-child, insert transfer, blok
182
+ * data handler contextParent, saver repair, validateHierarchy gate).
183
+ */
184
+ const parentExists =
185
+ newParentId === null || this.repository.getBlockById(newParentId) !== undefined;
186
+
187
+ if (!parentExists) {
188
+ const env = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
189
+ const isSyncingFromYjs = this.getIsSyncingFromYjs?.() === true;
190
+ const message =
191
+ `BlockHierarchy.setBlockParent: dangling parent id "${newParentId}" ` +
192
+ `for block "${block.id}" — parent block is not in the repository.`;
193
+
194
+ if (!isSyncingFromYjs && (env === 'test' || env === 'development')) {
195
+ throw new Error(message);
196
+ }
197
+
198
+ logLabeled(message, 'error');
199
+ }
200
+
201
+ const sanitizedParentId = parentExists ? newParentId : null;
202
+
57
203
  const oldParentId = block.parentId;
58
204
 
59
205
  // Remove from old parent's contentIds
@@ -85,7 +231,7 @@ export class BlockHierarchy {
85
231
  }
86
232
 
87
233
  // Add to new parent's contentIds
88
- const newParent = newParentId !== null ? this.repository.getBlockById(newParentId) : undefined;
234
+ const newParent = sanitizedParentId !== null ? this.repository.getBlockById(sanitizedParentId) : undefined;
89
235
  const shouldAddToNewParent = newParent !== undefined && !newParent.contentIds.includes(block.id);
90
236
 
91
237
  if (shouldAddToNewParent) {
@@ -94,19 +240,29 @@ export class BlockHierarchy {
94
240
 
95
241
  // Update block's parentId - parentId is a public mutable property on Block
96
242
  // eslint-disable-next-line no-param-reassign
97
- block.parentId = newParentId;
243
+ block.parentId = sanitizedParentId;
98
244
 
99
245
  // If the new parent's existing children are hidden (toggle is collapsed),
100
246
  // hide this newly added child too so Tab navigation skips it.
101
- if (newParentId !== null && newParent !== undefined) {
247
+ //
248
+ // Fix 5: a previously-empty collapsed container has no existing hidden
249
+ // children to infer state from. Fall back to reading the toggle/header
250
+ // tool's persistent open-state attribute (`data-blok-toggle-open="false"`)
251
+ // on any descendant of the parent holder.
252
+ if (sanitizedParentId !== null && newParent !== undefined) {
102
253
  const existingChildren = newParent.contentIds
103
254
  .filter(id => id !== block.id)
104
255
  .map(id => this.repository.getBlockById(id))
105
256
  .filter((b): b is NonNullable<typeof b> => b !== undefined);
106
257
 
107
- const parentIsCollapsed = existingChildren.length > 0 &&
258
+ const parentIsCollapsedFromChildren = existingChildren.length > 0 &&
108
259
  existingChildren.every(b => b.holder.classList.contains('hidden'));
109
260
 
261
+ const parentIsCollapsedFromAttr =
262
+ newParent.holder.querySelector('[data-blok-toggle-open="false"]') !== null;
263
+
264
+ const parentIsCollapsed = parentIsCollapsedFromChildren || parentIsCollapsedFromAttr;
265
+
110
266
  if (parentIsCollapsed) {
111
267
  block.holder.classList.add('hidden');
112
268
  }
@@ -116,7 +272,7 @@ export class BlockHierarchy {
116
272
  // honouring the flat-array order so the DOM order matches the logical order.
117
273
  // Skip if the holder is already claimed by another nested-blocks container
118
274
  // (e.g. a table cell) — moving it would steal it from that container.
119
- if (newParentId !== null && newParent !== undefined && !block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
275
+ if (sanitizedParentId !== null && newParent !== undefined && !block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
120
276
  const newContainer = newParent.holder.querySelector('[data-blok-toggle-children]');
121
277
  if (newContainer) {
122
278
  const allBlocks = this.repository.blocks;
@@ -134,8 +290,8 @@ export class BlockHierarchy {
134
290
  this.updateBlockIndentation(block);
135
291
 
136
292
  // Notify listener so parent data can be synced (e.g. to Yjs)
137
- if (newParentId !== null && this.onParentChanged !== undefined) {
138
- this.onParentChanged(newParentId);
293
+ if (sanitizedParentId !== null && this.onParentChanged !== undefined) {
294
+ this.onParentChanged(sanitizedParentId);
139
295
  }
140
296
  }
141
297
 
@@ -221,6 +221,7 @@ export class BlockOperations {
221
221
  tunes,
222
222
  skipYjsSync = false,
223
223
  appendToWorkingArea = false,
224
+ forceTopLevel = false,
224
225
  } = options;
225
226
 
226
227
  const targetIndex = index ?? this.currentBlockIndex + (replace ? 0 : 1);
@@ -277,13 +278,36 @@ export class BlockOperations {
277
278
  throw new Error(`Could not replace Block at index ${targetIndex}. Block not found.`);
278
279
  }
279
280
 
281
+ /**
282
+ * Capture the replaced block's parent link BEFORE it leaves the
283
+ * repository so the new block inherits the same container membership.
284
+ * Without this, a replace-insert inside a callout/toggle/table-cell
285
+ * child drops parentId and the Saver's derive-from-live-parentId
286
+ * fallback then emits the new block as a root sibling — the "callout
287
+ * paste ejection" regression family. Defense-in-depth counterpart to
288
+ * the paste() method's inheritance handling.
289
+ */
290
+ const replacedParentId = blockToReplace?.parentId ?? null;
291
+ const replacedBlockId = blockToReplace?.id;
292
+
280
293
  if (replace && blockToReplace !== undefined) {
281
294
  this.blockDidMutated(BlockRemovedMutationType, blockToReplace, {
282
295
  index: targetIndex,
283
296
  });
284
297
  }
285
298
 
286
- blocksStore.insert(targetIndex, block, replace, appendToWorkingArea);
299
+ blocksStore.insert(targetIndex, block, replace, appendToWorkingArea, forceTopLevel);
300
+
301
+ /**
302
+ * Transfer the parent link to the new block BEFORE Yjs sync so
303
+ * `addBlock` writes the final parentId in a single shot. Routing
304
+ * through `transferParentLinkToNewBlock` swaps the old id for the new
305
+ * one in the parent's contentIds while preserving its original
306
+ * position, matching the semantics used by `replace()`.
307
+ */
308
+ if (replace && replacedParentId !== null && replacedBlockId !== undefined) {
309
+ this.transferParentLinkToNewBlock(replacedBlockId, block, replacedParentId);
310
+ }
287
311
 
288
312
  /**
289
313
  * Update the raw currentBlockIndex BEFORE firing the mutation event so
@@ -342,9 +366,18 @@ export class BlockOperations {
342
366
  * @param needToFocus - If true, updates current Block index
343
367
  * @param skipYjsSync - If true, skip syncing to Yjs
344
368
  * @param blocksStore - The blocks store to modify
369
+ * @param forceTopLevel - If true, place new block at workingArea root level regardless of
370
+ * whether the predecessor in the flat array is nested. Used by Enter-at-start and
371
+ * Enter-at-end handlers when the current block is top-level.
345
372
  * @returns Inserted Block
346
373
  */
347
- public insertDefaultBlockAtIndex(index: number, needToFocus = false, skipYjsSync = false, blocksStore: BlocksStore): Block {
374
+ public insertDefaultBlockAtIndex(
375
+ index: number,
376
+ needToFocus = false,
377
+ skipYjsSync = false,
378
+ blocksStore: BlocksStore,
379
+ forceTopLevel = false
380
+ ): Block {
348
381
  const defaultTool = this.dependencies.config.defaultBlock;
349
382
 
350
383
  if (defaultTool === undefined) {
@@ -356,6 +389,7 @@ export class BlockOperations {
356
389
  index,
357
390
  needToFocus,
358
391
  skipYjsSync,
392
+ forceTopLevel,
359
393
  }, blocksStore);
360
394
  }
361
395
 
@@ -475,6 +509,26 @@ export class BlockOperations {
475
509
 
476
510
  const existingData = await block.data;
477
511
 
512
+ /**
513
+ * Layer 16: stale-source guard (regression: wrong-block-dropped family).
514
+ *
515
+ * `await block.data` is async — during that await, `block` can be removed
516
+ * by a Yjs remote delete, undo/redo, or tool conversion. When that happens
517
+ * `getBlockIndex(block)` returns -1 and `blocksStore.replace(-1, newBlock)`
518
+ * throws `Incorrect index`, aborting the surrounding batch mid-flight and
519
+ * leaving the flat blocks array inconsistent with the DOM — exactly the
520
+ * stale-state condition that lets drag drop an unrelated block.
521
+ *
522
+ * Abort cleanly: return the original block with no mutation or Yjs side
523
+ * effects. Revalidate AFTER the await, not before, so the guard covers
524
+ * the full async gap.
525
+ */
526
+ const blockIndex = this.repository.getBlockIndex(block);
527
+
528
+ if (blockIndex === -1) {
529
+ return block;
530
+ }
531
+
478
532
  const newBlock = this.factory.composeBlock({
479
533
  id: block.id,
480
534
  tool: block.name,
@@ -485,8 +539,6 @@ export class BlockOperations {
485
539
  bindEventsImmediately: true,
486
540
  });
487
541
 
488
- const blockIndex = this.repository.getBlockIndex(block);
489
-
490
542
  blocksStore.replace(blockIndex, newBlock);
491
543
 
492
544
  this.blockDidMutated(BlockChangedMutationType, newBlock, {
@@ -511,7 +563,50 @@ export class BlockOperations {
511
563
  }
512
564
 
513
565
  /**
514
- * Update the parentId of each child block to the given parent id.
566
+ * Attach `newBlock` to the old parent in place of the old block id,
567
+ * routing through `setBlockParent` so the DOM reparent/hide side effects
568
+ * run, then restoring the original position in the parent's contentIds[].
569
+ *
570
+ * Extracted from `replace()` to keep nesting depth under the lint cap.
571
+ * @param oldBlockId - The id of the block being replaced
572
+ * @param newBlock - The newly composed replacement block
573
+ * @param oldParentId - The parent id to transfer onto `newBlock`
574
+ */
575
+ private transferParentLinkToNewBlock(oldBlockId: string, newBlock: Block, oldParentId: string): void {
576
+ const parentBlock = this.repository.getBlockById(oldParentId);
577
+
578
+ if (parentBlock === undefined) {
579
+ // Parent already gone from the repository (e.g. race) — fall back to
580
+ // the bare parentId assignment so the new block at least carries the
581
+ // parent link for save/serialization.
582
+ // eslint-disable-next-line no-param-reassign
583
+ newBlock.parentId = oldParentId;
584
+
585
+ return;
586
+ }
587
+
588
+ const oldPositionInParent = parentBlock.contentIds.indexOf(oldBlockId);
589
+
590
+ this.hierarchy.setBlockParent(newBlock, oldParentId);
591
+
592
+ if (oldPositionInParent < 0) {
593
+ return;
594
+ }
595
+
596
+ const withoutStale = parentBlock.contentIds.filter(id => id !== oldBlockId && id !== newBlock.id);
597
+
598
+ withoutStale.splice(oldPositionInParent, 0, newBlock.id);
599
+ parentBlock.contentIds = withoutStale;
600
+ }
601
+
602
+ /**
603
+ * Route each child through `BlockHierarchy.setBlockParent` so that DOM
604
+ * reparenting (into the new parent's toggle-children container) and
605
+ * collapsed-state propagation run as a single atomic side effect per child.
606
+ *
607
+ * Direct `childBlock.parentId = ...` mutation is the same architectural bug
608
+ * as the callout paste-ejection family: the parent/content invariant is
609
+ * maintained but the DOM drifts from the logical tree until the next render.
515
610
  * @param childIds - Array of child block IDs to reparent
516
611
  * @param newParentId - New parent block ID
517
612
  */
@@ -520,7 +615,7 @@ export class BlockOperations {
520
615
  const childBlock = this.repository.getBlockById(childId);
521
616
 
522
617
  if (childBlock !== undefined) {
523
- childBlock.parentId = newParentId;
618
+ this.hierarchy.setBlockParent(childBlock, newParentId);
524
619
  }
525
620
  }
526
621
  }
@@ -534,6 +629,24 @@ export class BlockOperations {
534
629
  */
535
630
  public replace(block: Block, newTool: string, data: BlockToolData, blocksStore: BlocksStore): Block {
536
631
  const blockIndex = this.repository.getBlockIndex(block);
632
+
633
+ /**
634
+ * Layer 16: stale-source guard (regression: wrong-block-dropped family).
635
+ *
636
+ * `convert()` calls this after `await block.save()` — during that await
637
+ * the block can be removed by a Yjs remote delete or undo. A stale source
638
+ * here would drive `YjsManager.addBlock({...}, -1)` and
639
+ * `insert({ index: -1, replace: true })` — both feed negative indices
640
+ * into downstream splice paths that silently corrupt the flat array.
641
+ *
642
+ * Abort cleanly: return the original block with no Yjs or DOM side
643
+ * effects. The caller (conversion dropdown, paste) already tolerates a
644
+ * no-op outcome for a destroyed source.
645
+ */
646
+ if (blockIndex === -1) {
647
+ return block;
648
+ }
649
+
537
650
  const newBlockId = generateBlockId();
538
651
 
539
652
  // Capture hierarchy before replacement
@@ -560,15 +673,23 @@ export class BlockOperations {
560
673
  skipYjsSync: true,
561
674
  }, blocksStore);
562
675
 
563
- // Transfer hierarchy to new block
676
+ // Transfer hierarchy to new block.
677
+ //
678
+ // Route through `BlockHierarchy.setBlockParent` rather than mutating
679
+ // `newBlock.parentId` / `parentBlock.contentIds` directly, so that DOM
680
+ // reparenting (into the parent's toggle-children container) and
681
+ // collapsed-state propagation happen atomically. Direct mutation was the
682
+ // last remaining path that could leave a replaced child inside a
683
+ // callout/toggle rendered at the wrong DOM position until the next full
684
+ // render pass — same architectural shape as the callout paste-ejection
685
+ // bug family.
686
+ //
687
+ // Ordering concern: `setBlockParent` appends the new id to the parent's
688
+ // contentIds[], but `replace()` must preserve the OLD block's position.
689
+ // Capture the old index first, then run setBlockParent, then move the new
690
+ // id back into the captured slot and drop the (now-stale) old id.
564
691
  if (oldParentId !== null) {
565
- newBlock.parentId = oldParentId;
566
-
567
- const parentBlock = this.repository.getBlockById(oldParentId);
568
-
569
- if (parentBlock !== undefined) {
570
- parentBlock.contentIds = parentBlock.contentIds.map(id => id === block.id ? newBlock.id : id);
571
- }
692
+ this.transferParentLinkToNewBlock(block.id, newBlock, oldParentId);
572
693
  }
573
694
 
574
695
  /**
@@ -584,7 +705,11 @@ export class BlockOperations {
584
705
  (newTool === 'header' && (data as { isToggleable?: boolean }).isToggleable === true);
585
706
 
586
707
  if (oldContentIds.length > 0 && !newToolCanHostChildren) {
587
- // Promote each child to root level, inserting after the new block
708
+ // Promote each child to root level, inserting after the new block.
709
+ // Route through setBlockParent so the child holder is also moved out
710
+ // of the old (now-removed) parent's toggle-children container and any
711
+ // hidden/indentation state is recomputed — same reasoning as
712
+ // `reparentChildren` above.
588
713
  const insertAfterIndex = this.repository.getBlockIndex(newBlock);
589
714
 
590
715
  oldContentIds.forEach((childId, offset) => {
@@ -594,14 +719,17 @@ export class BlockOperations {
594
719
  return;
595
720
  }
596
721
 
597
- childBlock.parentId = null;
722
+ this.hierarchy.setBlockParent(childBlock, null);
598
723
  blocksStore.insert(insertAfterIndex + 1 + offset, childBlock, false, false);
599
724
  });
600
725
 
601
726
  newBlock.contentIds = [];
602
727
  } else {
728
+ // `reparentChildren` uses setBlockParent, which pushes each child id
729
+ // onto `newBlock.contentIds`. Reset it first so we don't end up with a
730
+ // pre-existing array plus appended ids.
731
+ newBlock.contentIds = [];
603
732
  this.reparentChildren(oldContentIds, newBlock.id);
604
- newBlock.contentIds = oldContentIds;
605
733
  }
606
734
 
607
735
  return newBlock;
@@ -684,15 +812,61 @@ export class BlockOperations {
684
812
  * @param blocksStore - The blocks store to modify
685
813
  */
686
814
  public async mergeBlocks(targetBlock: Block, blockToMerge: Block, blocksStore: BlocksStore): Promise<void> {
815
+ /**
816
+ * Layer 17: stale-source guard (regression: wrong-block-dropped family).
817
+ *
818
+ * `mergeBlocks` awaits `blockToMerge.data` (and `blockToMerge.exportDataAsString`
819
+ * in the conversion path), then re-awaits `targetBlock.data` inside
820
+ * `completeMerge`. During those awaits, either block can be removed by a
821
+ * Yjs remote delete, undo/redo, or a tool-conversion callback. The original
822
+ * code held closure references and used them unguarded after the awaits,
823
+ * which drove:
824
+ * - `YjsManager.transact` + `updateBlockData(targetBlock.id, …)` against a
825
+ * dead target id (silent no-op but still a mutation attempt)
826
+ * - `targetBlock.mergeWith(mergeData).then(…)` on a destroyed Block where
827
+ * `mergeWith` returns undefined → `.then` crash
828
+ * - `removeBlock(blockToMerge, …)` → `Can't find a Block to remove` thrown
829
+ * inside a `void ... .then(...)` chain → unhandled rejection
830
+ * - `currentBlockIndexValue = getBlockIndex(targetBlock)` → -1, corrupting
831
+ * caret state downstream
832
+ *
833
+ * Verify both blocks are still in the store before starting and also before
834
+ * each mutation step so a remote delete during any of the awaits aborts
835
+ * cleanly rather than propagating the stale reference into Yjs and the DOM.
836
+ */
837
+ if (
838
+ this.repository.getBlockIndex(targetBlock) === -1 ||
839
+ this.repository.getBlockIndex(blockToMerge) === -1
840
+ ) {
841
+ return;
842
+ }
843
+
687
844
  /**
688
845
  * Complete the merge operation with the prepared data
689
846
  * Syncs to Yjs atomically, then updates DOM without re-syncing
690
847
  */
691
848
  const completeMerge = async (mergeData: BlockToolData): Promise<void> => {
849
+ // Layer 17 re-check: post-await staleness window. Both blocks must still
850
+ // be in the store, otherwise abort before any Yjs/DOM mutation.
851
+ if (
852
+ this.repository.getBlockIndex(targetBlock) === -1 ||
853
+ this.repository.getBlockIndex(blockToMerge) === -1
854
+ ) {
855
+ return;
856
+ }
857
+
692
858
  // Get current target data to compute merged result for Yjs
693
859
  const targetData = await targetBlock.data;
694
860
  const mergedData = { ...targetData, ...mergeData };
695
861
 
862
+ // Layer 17 re-check after the second await.
863
+ if (
864
+ this.repository.getBlockIndex(targetBlock) === -1 ||
865
+ this.repository.getBlockIndex(blockToMerge) === -1
866
+ ) {
867
+ return;
868
+ }
869
+
696
870
  // Sync to Yjs atomically: update target + remove source as single undo entry
697
871
  this.dependencies.YjsManager.transact(() => {
698
872
  for (const [key, value] of Object.entries(mergedData)) {
@@ -957,7 +1131,7 @@ export class BlockOperations {
957
1131
  this.hierarchy.setBlockParent(newBlock, parentId);
958
1132
 
959
1133
  return newBlock;
960
- });
1134
+ }, { extendThroughRAF: true });
961
1135
  }
962
1136
 
963
1137
  /**
@@ -1033,11 +1207,27 @@ export class BlockOperations {
1033
1207
  replace = false,
1034
1208
  blocksStore: BlocksStore
1035
1209
  ): Promise<Block> {
1036
- // Capture predecessor's parentId BEFORE insert (for non-replace, the
1037
- // predecessor is the current block; its parentId should be inherited).
1038
- const predecessorParentId = !replace
1039
- ? this.currentBlock?.parentId ?? null
1040
- : null;
1210
+ // Capture predecessor's parentId and id BEFORE insert. The predecessor is
1211
+ // the current block whether we're replacing it in place or inserting
1212
+ // after it, the new block belongs to the same parent. Without this, pasting
1213
+ // into a nested empty block (e.g. a paragraph inside a callout) via the
1214
+ // replace=true path strands the new block as a root sibling once Saver
1215
+ // re-derives content[] from live parentIds.
1216
+ //
1217
+ // Title-vs-child defense: when the caret is in the CONTAINER's own title
1218
+ // input (the header of a toggle/callout) rather than inside one of its
1219
+ // children, the new block should become a CHILD of the container — its
1220
+ // parent must be the container's id, NOT the container's parentId.
1221
+ // Mirrors the `contextParentId` logic in BasePasteHandler.insertPasteData
1222
+ // and BlokDataHandler so all paste entry points agree.
1223
+ const currentBlock = this.currentBlock;
1224
+ const childContainer = currentBlock?.holder?.querySelector('[data-blok-toggle-children]') ?? null;
1225
+ const isInContainerTitle = childContainer !== null &&
1226
+ !childContainer.contains(currentBlock?.currentInput ?? null);
1227
+ const predecessorParentId = isInContainerTitle
1228
+ ? (currentBlock?.id ?? null)
1229
+ : (currentBlock?.parentId ?? null);
1230
+ const oldBlockId = replace ? currentBlock?.id : undefined;
1041
1231
 
1042
1232
  // Insert block without syncing to Yjs yet.
1043
1233
  // Wrap in atomic operation so that child blocks created during rendered()
@@ -1066,9 +1256,16 @@ export class BlockOperations {
1066
1256
  block.refreshToolRootElement();
1067
1257
  });
1068
1258
 
1069
- // Inherit parentId from predecessor for non-replace inserts.
1070
- if (!replace && predecessorParentId !== null) {
1071
- this.hierarchy.setBlockParent(block, predecessorParentId);
1259
+ // Wire the new block into the predecessor's parent BEFORE the Yjs addBlock
1260
+ // call below so Yjs sees the final parentId in one shot. For replace we
1261
+ // route through `transferParentLinkToNewBlock` which swaps the old id for
1262
+ // the new id inside the parent's contentIds while preserving position.
1263
+ if (predecessorParentId !== null) {
1264
+ if (replace && oldBlockId !== undefined) {
1265
+ this.transferParentLinkToNewBlock(oldBlockId, block, predecessorParentId);
1266
+ } else {
1267
+ this.hierarchy.setBlockParent(block, predecessorParentId);
1268
+ }
1072
1269
  }
1073
1270
 
1074
1271
  // Sync final state to Yjs as single operation
@@ -54,6 +54,12 @@ export interface InsertBlockOptions {
54
54
  skipYjsSync?: boolean;
55
55
  /** When true, append block to workingArea instead of positioning relative to adjacent blocks */
56
56
  appendToWorkingArea?: boolean;
57
+ /**
58
+ * When true, force DOM placement at workingArea root level even if the previous block
59
+ * in the flat array is nested. Prevents the Enter-after-callout regression family where
60
+ * a top-level insertion would otherwise land inside a nested container.
61
+ */
62
+ forceTopLevel?: boolean;
57
63
  }
58
64
 
59
65
  /**
@@ -111,7 +117,13 @@ export interface ConvertBlockOptions {
111
117
  */
112
118
  export type BlocksStore = Blocks & {
113
119
  [index: number]: Block | undefined;
114
- insert(index: number, block: Block, replace?: boolean, appendToWorkingArea?: boolean): void;
120
+ insert(
121
+ index: number,
122
+ block: Block,
123
+ replace?: boolean,
124
+ appendToWorkingArea?: boolean,
125
+ forceTopLevel?: boolean
126
+ ): void;
115
127
  remove(index: number): void;
116
128
  move(toIndex: number, fromIndex: number, skipDOM?: boolean, skipMovedHook?: boolean): void;
117
129
  };