@jackuait/blok 0.10.11 → 0.10.12

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.
@@ -3,7 +3,7 @@
3
3
  * @classdesc Handles state-changing operations on blocks
4
4
  * @module BlockOperations
5
5
  */
6
- import type { BlockToolData, PasteEvent, SanitizerConfig , BlokConfig } from '../../../../types';
6
+ import type { BlockToolData, PasteEvent, SanitizerConfig, BlokConfig, OutputBlockData } from '../../../../types';
7
7
  import type { BlockTuneData } from '../../../../types/block-tunes/block-tune-data';
8
8
  import type { BlockMutationType } from '../../../../types/events/block';
9
9
  import { BlockAddedMutationType } from '../../../../types/events/block/BlockAdded';
@@ -17,6 +17,7 @@ import type { BlokEventMap } from '../../events';
17
17
  import { isEmpty, isObject, isString, log, generateBlockId } from '../../utils';
18
18
  import { announce } from '../../utils/announcer';
19
19
  import { convertStringToBlockData, isBlockConvertable } from '../../utils/blocks';
20
+ import { validateHierarchy } from '../../utils/hierarchy-invariant';
20
21
  import type { EventsDispatcher } from '../../utils/events';
21
22
  import { sanitizeBlocks, clean, composeSanitizerConfig } from '../../utils/sanitizer';
22
23
  import { isInsideTableCell, isRestrictedInTableCell } from '../../../tools/table/table-restrictions';
@@ -357,6 +358,8 @@ export class BlockOperations {
357
358
  this.dependencies.YjsManager?.stopCapturing();
358
359
  }
359
360
 
361
+ this.assertHierarchyInvariantInDev('insert');
362
+
360
363
  return block;
361
364
  }
362
365
 
@@ -491,6 +494,8 @@ export class BlockOperations {
491
494
  this.currentBlockIndexValue = 0;
492
495
  }
493
496
 
497
+ this.assertHierarchyInvariantInDev('removeBlock');
498
+
494
499
  resolve();
495
500
  });
496
501
  }
@@ -732,6 +737,8 @@ export class BlockOperations {
732
737
  this.reparentChildren(oldContentIds, newBlock.id);
733
738
  }
734
739
 
740
+ this.assertHierarchyInvariantInDev('replace');
741
+
735
742
  return newBlock;
736
743
  }
737
744
 
@@ -767,6 +774,23 @@ export class BlockOperations {
767
774
  return;
768
775
  }
769
776
 
777
+ /**
778
+ * Defense-in-depth: capture the destination's parentId BEFORE the flat
779
+ * reorder so we can auto-heal cross-container moves below.
780
+ *
781
+ * `move()` is only a flat-array reorder — it does NOT touch parentId or
782
+ * the source/destination container `contentIds`. Without an auto-heal,
783
+ * any caller that drags or keyboard-shuffles a block past a container
784
+ * boundary leaves `parentId` stale: the block lands visually inside the
785
+ * new container but still claims the old one. That's the exact drift
786
+ * the cross-parent merge guard already blocks at the merge layer; we
787
+ * mirror the defense here so the same bug family can never re-enter
788
+ * via the move pipeline. DragController already calls setBlockParent
789
+ * after move(); the auto-heal below makes that a no-op (idempotent),
790
+ * and rescues every other caller (keyboard moveUp/Down, public api).
791
+ */
792
+ const destinationParentId = neighborBlock !== undefined ? neighborBlock.parentId : null;
793
+
770
794
  // Suppress stopCapturing to keep DOM + Yjs move as single undo entry
771
795
  this.suppressStopCapturing = true;
772
796
  try {
@@ -790,6 +814,19 @@ export class BlockOperations {
790
814
  throw new Error(`Could not move Block. Block at index ${toIndex} is not available.`);
791
815
  }
792
816
 
817
+ /**
818
+ * Cross-container auto-heal — see the comment above destinationParentId.
819
+ *
820
+ * Routes through `setBlockParent`, which is the canonical chokepoint
821
+ * that updates BOTH the moved block's `parentId` AND the source/dest
822
+ * container `contentIds` arrays. Idempotent when the parent already
823
+ * matches, so DragController's existing post-move setBlockParent call
824
+ * remains a safe no-op.
825
+ */
826
+ if (movedBlock.parentId !== destinationParentId) {
827
+ this.hierarchy.setBlockParent(movedBlock, destinationParentId);
828
+ }
829
+
793
830
  /**
794
831
  * Force call of didMutated event on Block movement
795
832
  */
@@ -800,6 +837,8 @@ export class BlockOperations {
800
837
 
801
838
  // Sync to Yjs using the actual resolved index
802
839
  this.dependencies.YjsManager.moveBlock(movedBlock.id, resolvedIndex);
840
+
841
+ this.assertHierarchyInvariantInDev('move');
803
842
  } finally {
804
843
  this.suppressStopCapturing = false;
805
844
  }
@@ -841,6 +880,27 @@ export class BlockOperations {
841
880
  return;
842
881
  }
843
882
 
883
+ /**
884
+ * Defense-in-depth: refuse to merge across container boundaries.
885
+ *
886
+ * Every block belongs to a logical container identified by `parentId`
887
+ * (null = root, or the id of a table/toggle/callout/header/database-row
888
+ * block). Merging across containers silently mangles the hierarchy —
889
+ * the source block's data is appended to a target in a DIFFERENT
890
+ * container, and the source is then deleted, losing content and
891
+ * breaking the invariant that a block lives under exactly one parent.
892
+ *
893
+ * keyboardNavigation already guards Backspace/Delete at cell/toggle
894
+ * boundaries, but a missed guard (or a future composer that forgets
895
+ * the check) must fail safe at this layer instead of corrupting data.
896
+ * This is the root-cause fix for the "Enter-then-Backspace-inside-a-
897
+ * table-cell" bug family — any similar bug in any nested-container
898
+ * tool is prevented here.
899
+ */
900
+ if (targetBlock.parentId !== blockToMerge.parentId) {
901
+ return;
902
+ }
903
+
844
904
  /**
845
905
  * Complete the merge operation with the prepared data
846
906
  * Syncs to Yjs atomically, then updates DOM without re-syncing
@@ -1070,6 +1130,8 @@ export class BlockOperations {
1070
1130
  this.hierarchy.setBlockParent(newBlock, currentBlock.parentId);
1071
1131
  }
1072
1132
 
1133
+ this.assertHierarchyInvariantInDev('splitBlockWithData');
1134
+
1073
1135
  return newBlock;
1074
1136
  });
1075
1137
  }
@@ -1090,7 +1152,7 @@ export class BlockOperations {
1090
1152
  * @param blocksStore - The blocks store to modify
1091
1153
  * @returns the newly created child block
1092
1154
  */
1093
- public insertInsideParent(parentId: string, insertIndex: number, blocksStore: BlocksStore): Block {
1155
+ public insertInsideParent(parentId: string, insertIndex: number, blocksStore: BlocksStore, childData?: BlockToolData): Block {
1094
1156
  const parentBlock = this.repository.getBlockById(parentId);
1095
1157
 
1096
1158
  if (parentBlock === undefined) {
@@ -1098,14 +1160,16 @@ export class BlockOperations {
1098
1160
  }
1099
1161
 
1100
1162
  const newBlockId = generateBlockId();
1163
+ const defaultBlockTool = this.dependencies.config.defaultBlock ?? 'paragraph';
1164
+ const resolvedChildData = childData ?? { text: '' };
1101
1165
 
1102
1166
  return this.yjsSync.withAtomicOperation(() => {
1103
1167
  // Atomic Yjs transaction: add new block with parent (single undo entry)
1104
1168
  this.dependencies.YjsManager.transact(() => {
1105
1169
  this.dependencies.YjsManager.addBlock({
1106
1170
  id: newBlockId,
1107
- type: this.dependencies.config.defaultBlock ?? 'paragraph',
1108
- data: { text: '' },
1171
+ type: defaultBlockTool,
1172
+ data: resolvedChildData,
1109
1173
  parent: parentId,
1110
1174
  }, insertIndex);
1111
1175
  });
@@ -1113,8 +1177,8 @@ export class BlockOperations {
1113
1177
  // Insert DOM block (skip Yjs sync — already done above)
1114
1178
  const newBlock = this.insert({
1115
1179
  id: newBlockId,
1116
- tool: this.dependencies.config.defaultBlock ?? 'paragraph',
1117
- data: { text: '' },
1180
+ tool: defaultBlockTool,
1181
+ data: resolvedChildData,
1118
1182
  index: insertIndex,
1119
1183
  needToFocus: false,
1120
1184
  skipYjsSync: true,
@@ -1130,6 +1194,8 @@ export class BlockOperations {
1130
1194
  // otherwise create a second Yjs undo entry for the toggle data update, splitting undo).
1131
1195
  this.hierarchy.setBlockParent(newBlock, parentId);
1132
1196
 
1197
+ this.assertHierarchyInvariantInDev('insertInsideParent');
1198
+
1133
1199
  return newBlock;
1134
1200
  }, { extendThroughRAF: true });
1135
1201
  }
@@ -1190,7 +1256,50 @@ export class BlockOperations {
1190
1256
  ? Object.assign(baseBlockData, blockDataOverrides)
1191
1257
  : baseBlockData;
1192
1258
 
1193
- return this.replace(blockToConvert, replacingTool.name, newBlockData, blocksStore);
1259
+ /**
1260
+ * Bracket the whole convert in a single undo group.
1261
+ *
1262
+ * Two things can split a convert across multiple Cmd+Z entries if left
1263
+ * unchecked:
1264
+ *
1265
+ * 1. Container tools (callout) seed a first child paragraph inside their
1266
+ * `rendered()` hook via `api.blocks.insertInsideParent`, which normally
1267
+ * forces a new undo boundary via `stopCapturing()`.
1268
+ *
1269
+ * 2. ANY tool can accept `{text}` on conversion but then populate extra
1270
+ * fields (e.g. toggle's `isOpen: true`) during its first `save()` pass.
1271
+ * That first save is triggered by the MutationObserver watching the
1272
+ * brand-new block's DOM, and its `syncBlockDataToYjs` would write the
1273
+ * extra fields as a *separate* Yjs transaction — creating a phantom
1274
+ * post-convert undo entry so Cmd+Z needs two presses.
1275
+ *
1276
+ * We solve (1) with `suppressStopCapturing` (no new undo boundary) and
1277
+ * (2) with `yjsSync.withAtomicOperation({ extendThroughRAF: true })` which
1278
+ * keeps `isSyncingFromYjs = true` through the next animation frame, so
1279
+ * mutation-triggered `syncBlockDataToYjs` calls are suppressed for
1280
+ * rendered()/first-save writes. The tool's real data persists because
1281
+ * `replace()` already wrote it into Yjs via its own transaction.
1282
+ */
1283
+ this.dependencies.YjsManager.stopCapturing();
1284
+ const prevSuppress = this.suppressStopCapturing;
1285
+
1286
+ this.suppressStopCapturing = true;
1287
+
1288
+ try {
1289
+ return this.yjsSync.withAtomicOperation(
1290
+ () => this.replace(blockToConvert, replacingTool.name, newBlockData, blocksStore),
1291
+ { extendThroughRAF: true }
1292
+ );
1293
+ } finally {
1294
+ // Close the undo group after the sync `replace()` and any synchronous
1295
+ // `rendered()` → `insertInsideParent` have landed, but wait one microtask
1296
+ // so DOM MutationObserver-triggered Yjs writes settle inside the same
1297
+ // entry.
1298
+ queueMicrotask(() => {
1299
+ this.suppressStopCapturing = prevSuppress;
1300
+ this.dependencies.YjsManager.stopCapturing();
1301
+ });
1302
+ }
1194
1303
  }
1195
1304
 
1196
1305
  /**
@@ -1232,6 +1341,16 @@ export class BlockOperations {
1232
1341
  // Insert block without syncing to Yjs yet.
1233
1342
  // Wrap in atomic operation so that child blocks created during rendered()
1234
1343
  // (e.g., table cell paragraph blocks) also skip Yjs sync.
1344
+ //
1345
+ // `extendThroughRAF: true` keeps `isSyncingFromYjs` elevated past the end
1346
+ // of this sync closure and through the next animation frame. Without it,
1347
+ // the cleanup runs immediately on return and the subsequent
1348
+ // `await block.ready` → `onPaste` → `addBlock` microtask chain would see
1349
+ // `isSyncingFromYjs === false`. Any MutationObserver-triggered first
1350
+ // `save()` on the freshly rendered block would then land as a separate
1351
+ // Yjs transaction *before* the authoritative `YjsManager.addBlock()` call
1352
+ // below, producing a phantom post-paste undo entry. Mirrors the guard in
1353
+ // `convert()` for the same bug class.
1235
1354
  const block = this.yjsSync.withAtomicOperation(() => {
1236
1355
  return this.insert({
1237
1356
  tool: toolName,
@@ -1239,7 +1358,7 @@ export class BlockOperations {
1239
1358
  needToFocus: false,
1240
1359
  skipYjsSync: true,
1241
1360
  }, blocksStore);
1242
- });
1361
+ }, { extendThroughRAF: true });
1243
1362
 
1244
1363
  // Update currentBlockIndex AFTER insert (and handleBlockMutation) completes.
1245
1364
  this.currentBlockIndex = this.repository.getBlockIndex(block);
@@ -1251,10 +1370,19 @@ export class BlockOperations {
1251
1370
 
1252
1371
  // Call onPaste within atomic operation so child blocks created
1253
1372
  // during cell initialization also skip Yjs sync.
1373
+ //
1374
+ // `extendThroughRAF: true` is critical for tools whose `onPaste()`
1375
+ // performs async DOM mutation — e.g. database card drawer dynamic
1376
+ // `import('../../blok')`, code tool shiki/mermaid/katex imports.
1377
+ // Without it, the atomic-op cleanup fires synchronously on return
1378
+ // and the async work lands after `isSyncingFromYjs` flips back to
1379
+ // false, letting MutationObserver-triggered `syncBlockDataToYjs`
1380
+ // calls on the fresh block become a separate Yjs transaction — the
1381
+ // same phantom-undo bug class as the insert-time wrap above.
1254
1382
  this.yjsSync.withAtomicOperation(() => {
1255
1383
  block.call(BlockToolAPI.ON_PASTE, pasteEvent as unknown as Record<string, unknown>);
1256
1384
  block.refreshToolRootElement();
1257
- });
1385
+ }, { extendThroughRAF: true });
1258
1386
 
1259
1387
  // Wire the new block into the predecessor's parent BEFORE the Yjs addBlock
1260
1388
  // call below so Yjs sees the final parentId in one shot. For replace we
@@ -1358,4 +1486,56 @@ export class BlockOperations {
1358
1486
  this.dependencies.Caret.setToBlock(block, this.dependencies.Caret.positions.END);
1359
1487
  }
1360
1488
  }
1489
+
1490
+ /**
1491
+ * Dev/test invariant gate.
1492
+ *
1493
+ * Validates the parent/contentIds bidirectional invariant against the live
1494
+ * repository. Gated behind NODE_ENV so prod stays free, but every test run
1495
+ * and dev session asserts it after every BlockOperations mutation. Catches
1496
+ * any future regression that would corrupt the hierarchy at the point of
1497
+ * introduction instead of one save cycle later — closing the last gap in
1498
+ * the "callout/table/toggle ejection" bug family by instrumenting the
1499
+ * mutation pipeline itself.
1500
+ *
1501
+ * The save-time gate (saver) and the setBlockParent dangling-id guard are
1502
+ * defenses at the boundaries; this is the defense at the core.
1503
+ *
1504
+ * Filters out the saver-repairable violation kinds (`child-parent-missing`,
1505
+ * `content-id-dangling`) because complex multi-step ops legitimately pass
1506
+ * through transient orphan states between sub-operations. The gate fires
1507
+ * only on the irreversible drift kinds: bidirectional divergence
1508
+ * (`child-not-in-parent-content`, `content-parent-mismatch`) and duplicate
1509
+ * content ids (`content-duplicate`) — the patterns the callout/table/toggle
1510
+ * ejection bug family exhibits.
1511
+ */
1512
+ private assertHierarchyInvariantInDev(context: string): void {
1513
+ const env = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
1514
+
1515
+ if (env !== 'test' && env !== 'development') {
1516
+ return;
1517
+ }
1518
+
1519
+ const blocks: OutputBlockData[] = this.repository.blocks.map(b => ({
1520
+ id: b.id,
1521
+ type: b.name,
1522
+ data: {},
1523
+ ...(b.parentId !== null && b.parentId !== undefined ? { parent: b.parentId } : {}),
1524
+ ...(Array.isArray(b.contentIds) && b.contentIds.length > 0 ? { content: [...b.contentIds] } : {}),
1525
+ } as OutputBlockData));
1526
+
1527
+ const violations = validateHierarchy(blocks).filter(v =>
1528
+ v.kind === 'child-not-in-parent-content' ||
1529
+ v.kind === 'content-parent-mismatch' ||
1530
+ v.kind === 'content-duplicate'
1531
+ );
1532
+
1533
+ if (violations.length === 0) {
1534
+ return;
1535
+ }
1536
+
1537
+ const summary = violations.map(v => ` - ${v.message}`).join('\n');
1538
+
1539
+ throw new Error(`Hierarchy invariant violated at BlockOperations.${context}:\n${summary}`);
1540
+ }
1361
1541
  }
@@ -802,6 +802,47 @@ export class Caret extends Module {
802
802
  return true;
803
803
  }
804
804
 
805
+ /**
806
+ * If both blocks share the same DOM container (e.g., same table cell),
807
+ * navigate directly to the previous block instead of exiting the container.
808
+ * Symmetric to navigateVerticalNext — without this guard, ArrowUp from the
809
+ * first child of a callout/toggle/table cell would silently escape the
810
+ * container even though a sibling block sits right above it in the same
811
+ * DOM container.
812
+ */
813
+ if (previousBlock !== null && currentBlock.parentId !== null &&
814
+ currentBlock.holder.parentElement !== null &&
815
+ currentBlock.holder.parentElement === previousBlock.holder.parentElement) {
816
+ this.setToBlockAtXPosition(previousBlock, caretX, false);
817
+
818
+ return true;
819
+ }
820
+
821
+ /**
822
+ * If current block is inside a container, the "previous block" in the flat
823
+ * array may belong to a DIFFERENT cell of the SAME parent (e.g., a sibling
824
+ * cell of the same table). Navigating to it would jump across cells, which
825
+ * Notion-style navigation should treat as exiting the container UP.
826
+ */
827
+ const shouldExitParent = currentBlock.parentId !== null && (
828
+ previousBlock === null ||
829
+ previousBlock.parentId === currentBlock.parentId ||
830
+ previousBlock.id === currentBlock.parentId
831
+ );
832
+
833
+ if (shouldExitParent && currentBlock.parentId !== null) {
834
+ const blockBeforeContainer = this.findFirstBlockBeforeParent(currentBlock.parentId);
835
+
836
+ if (blockBeforeContainer !== null) {
837
+ this.setToBlockAtXPosition(blockBeforeContainer, caretX, false);
838
+
839
+ return true;
840
+ }
841
+
842
+ // No block before container — we're at the top of the document.
843
+ return false;
844
+ }
845
+
805
846
  /**
806
847
  * Navigate to previous block, preserving horizontal position
807
848
  */
@@ -814,6 +855,22 @@ export class Caret extends Module {
814
855
  return false;
815
856
  }
816
857
 
858
+ /**
859
+ * Find the first block before a parent block (e.g., a table) — the block
860
+ * sitting immediately above the parent in the flat block array.
861
+ */
862
+ private findFirstBlockBeforeParent(parentBlockId: string): Block | null {
863
+ const { BlockManager } = this.Blok;
864
+ const blocks = BlockManager.blocks;
865
+ const parentIndex = blocks.findIndex(b => b.id === parentBlockId);
866
+
867
+ if (parentIndex <= 0) {
868
+ return null;
869
+ }
870
+
871
+ return blocks[parentIndex - 1];
872
+ }
873
+
817
874
  /**
818
875
  * Find the first block after a parent block (e.g., a table) by scanning the flat
819
876
  * block array and skipping blocks whose parentId matches.
@@ -239,12 +239,19 @@ export class DragOperations {
239
239
  return { duplicatedBlocks: [], targetIndex: prep.baseInsertIndex };
240
240
  }
241
241
 
242
- // Insert duplicated blocks
242
+ // Insert duplicated blocks.
243
+ //
244
+ // Deep-clone `saved.data` and `saved.tunes` so the duplicate does not share
245
+ // nested structures with the source. Tools like `table` return arrays from
246
+ // their internal state straight out of `save()`, so a shallow pass would
247
+ // leave the duplicate and the original mutating each other's `content`
248
+ // until the next save cycle — a silent data-corruption class of bug in the
249
+ // same family as the nested-container ejection regressions.
243
250
  const duplicatedBlocks = prep.validResults.map(({ saved, toolName }, index) =>
244
251
  this.blockManager.insert({
245
252
  tool: toolName,
246
- data: saved.data,
247
- tunes: saved.tunes,
253
+ data: structuredClone(saved.data),
254
+ tunes: structuredClone(saved.tunes),
248
255
  index: prep.baseInsertIndex + index,
249
256
  needToFocus: false,
250
257
  })
@@ -1458,6 +1458,11 @@
1458
1458
  @apply bg-search-input-bg rounded-[10px] transition-colors duration-150 w-fit max-w-[240px] px-2;
1459
1459
  }
1460
1460
 
1461
+ [data-blok-table-cell] [data-blok-slash-search],
1462
+ [data-blok-table-cell] [data-blok-slash-search]:focus-visible {
1463
+ @apply rounded-[6px];
1464
+ }
1465
+
1461
1466
  [data-blok-slash-search]::after {
1462
1467
  content: attr(data-blok-slash-search);
1463
1468
  @apply text-gray-text font-medium text-base pointer-events-none;
@@ -67,10 +67,26 @@ export class CalloutTool implements BlockTool {
67
67
  private _emojiPicker: EmojiPicker | null = null;
68
68
  private _colorPicker: ColorPickerHandle | null = null;
69
69
  private blockId?: string;
70
+ /**
71
+ * Text captured from a source block during conversion (paragraph -> callout).
72
+ * Callout stores its rich content inside child blocks rather than in `data`,
73
+ * so the first-time `rendered()` hook seeds a child paragraph with this
74
+ * text — preserving the original content across the conversion.
75
+ */
76
+ private _pendingChildText: string | null = null;
70
77
 
71
78
  constructor({ data, api, readOnly, block }: BlockToolConstructorOptions<CalloutData, CalloutConfig>) {
72
79
  this.api = api;
73
80
  this.readOnly = readOnly;
81
+
82
+ const importedText = typeof (data as Record<string, unknown>).__importedText === 'string'
83
+ ? (data as Record<string, unknown>).__importedText as string
84
+ : null;
85
+
86
+ if (importedText !== null && importedText.length > 0) {
87
+ this._pendingChildText = importedText;
88
+ }
89
+
74
90
  this._data = this.normalizeData(data);
75
91
 
76
92
  if (block) {
@@ -156,13 +172,23 @@ export class CalloutTool implements BlockTool {
156
172
  const blockIndex = this.api.blocks.getBlockIndex(this.blockId);
157
173
 
158
174
  if (blockIndex !== undefined) {
159
- const newBlock = this.api.blocks.insertInsideParent(this.blockId, blockIndex + 1);
175
+ // If conversion handed us source text to preserve, seed the first
176
+ // child paragraph with it (single-shot — cleared immediately).
177
+ const seedText = this._pendingChildText;
178
+
179
+ this._pendingChildText = null;
180
+
181
+ const childData = seedText !== null && seedText.length > 0
182
+ ? { text: seedText }
183
+ : undefined;
184
+
185
+ const newBlock = this.api.blocks.insertInsideParent(this.blockId, blockIndex + 1, childData);
160
186
 
161
187
  // Manually append the new child's holder — insertInsideParent places it in the
162
188
  // flat block list but doesn't know about our childContainer DOM.
163
189
  this._dom.childContainer.appendChild(newBlock.holder);
164
190
 
165
- this.api.caret.setToBlock(newBlock.id, 'start');
191
+ this.api.caret.setToBlock(newBlock.id, seedText !== null ? 'end' : 'start');
166
192
  }
167
193
  }
168
194
  }
@@ -392,11 +418,20 @@ export class CalloutTool implements BlockTool {
392
418
 
393
419
  public static get conversionConfig(): ConversionConfig<CalloutData> {
394
420
  return {
395
- import: (): CalloutData => ({
421
+ /**
422
+ * Callout stores its text inside child blocks, not in its own `data`.
423
+ * On import we capture the source block's text through a transient
424
+ * `__importedText` field that the callout constructor reads and the
425
+ * `rendered()` hook uses to seed the first child paragraph with the
426
+ * original content — preserving the text across paragraph -> callout
427
+ * conversion.
428
+ */
429
+ import: (stringToImport: string): CalloutData => ({
396
430
  emoji: DEFAULT_EMOJI,
397
431
  textColor: null,
398
432
  backgroundColor: null,
399
- }),
433
+ __importedText: stringToImport,
434
+ } as CalloutData),
400
435
  };
401
436
  }
402
437
 
@@ -675,9 +675,10 @@ export class Table implements BlockTool {
675
675
  // (blocks deleted but matrix not updated); persisting them causes DOM
676
676
  // node stealing and data loss on subsequent renders.
677
677
  const tableId = this.blockId ?? '';
678
+ const gridEl = this.gridElement;
678
679
 
679
- data.content = data.content.map(row =>
680
- row.map(cell => {
680
+ data.content = data.content.map((row, rowIndex) =>
681
+ row.map((cell, colIndex) => {
681
682
  if (!isCellWithBlocks(cell)) {
682
683
  return cell;
683
684
  }
@@ -688,6 +689,35 @@ export class Table implements BlockTool {
688
689
  return block != null && (block.parentId ?? '') === tableId;
689
690
  });
690
691
 
692
+ // Recover from a stale model snapshot: if the model says this cell is
693
+ // empty but the live DOM still has child blocks parented to the table
694
+ // here, harvest their ids from the DOM. Without this guard, a
695
+ // mid-render snapshot can persist empty cells to Yjs and become an
696
+ // attractor state that later undo presses revert to — see
697
+ // table-undo-redo-orphans regression.
698
+ if (filtered.length === 0 && gridEl) {
699
+ const cellEl = gridEl.querySelector<HTMLElement>(
700
+ `[${CELL_ROW_ATTR}="${rowIndex}"][${CELL_COL_ATTR}="${colIndex}"]`
701
+ );
702
+ const container = cellEl?.querySelector<HTMLElement>(`[${CELL_BLOCKS_ATTR}]`);
703
+ const harvested = container
704
+ ? Array.from(container.querySelectorAll<HTMLElement>('[data-blok-id]'))
705
+ .map(el => el.getAttribute('data-blok-id') ?? '')
706
+ .filter(id => {
707
+ if (!id) {
708
+ return false;
709
+ }
710
+ const block = this.api.blocks.getById?.(id);
711
+
712
+ return block != null && (block.parentId ?? '') === tableId;
713
+ })
714
+ : [];
715
+
716
+ if (harvested.length > 0) {
717
+ return { ...cell, blocks: harvested };
718
+ }
719
+ }
720
+
691
721
  return { ...cell, blocks: filtered };
692
722
  })
693
723
  );
@@ -772,6 +802,8 @@ export class Table implements BlockTool {
772
802
  return;
773
803
  }
774
804
 
805
+ const isSyncReplay = this.api.blocks.isSyncingFromYjs;
806
+
775
807
  this.runStructuralOp(() => {
776
808
  const setDataContent = this.cellBlocks?.initializeCells(this.initialContent ?? []) ?? this.initialContent ?? [];
777
809
 
@@ -782,9 +814,14 @@ export class Table implements BlockTool {
782
814
  return;
783
815
  }
784
816
 
785
- // When undoing reverts content to empty, the grid has default dimensions
786
- // but initializeCells([]) mounted zero blocks. Pre-populate the model
787
- // with empty cell entries so populateNewCells can place blocks correctly.
817
+ // When an undo replay reverts content to empty, the DOM grid has its
818
+ // default dimensions but the model has zero rows. Reflect the grid
819
+ // shape in the model with empty cells so subsequent operations have
820
+ // valid bounds. Do NOT call populateNewCells here — fabricating new
821
+ // paragraph blocks during a Yjs replay creates orphans that survive
822
+ // the next undo cycle (regression: table-undo-redo-orphans).
823
+ // If Yjs actually contains child blocks for those cells they will
824
+ // arrive via separate block-add events.
788
825
  if (this.api.blocks.isSyncingFromYjs && setDataContent.length === 0 && gridEl) {
789
826
  const emptyGridContent = Array.from(gridEl.querySelectorAll(`[${ROW_ATTR}]`), (row) => {
790
827
  const cellCount = row.querySelectorAll(`[${CELL_ATTR}]`).length;
@@ -796,8 +833,6 @@ export class Table implements BlockTool {
796
833
  ...this.model.snapshot(),
797
834
  content: emptyGridContent,
798
835
  });
799
-
800
- populateNewCells(gridEl, this.cellBlocks);
801
836
  } else {
802
837
  this.model.replaceAll({
803
838
  ...this.model.snapshot(),
@@ -825,6 +860,23 @@ export class Table implements BlockTool {
825
860
  const snapSet = this.model.snapshot();
826
861
  applyCellColors(gridEl, snapSet.content);
827
862
  applyCellPlacements(gridEl, snapSet.content);
863
+
864
+ if (isSyncReplay) {
865
+ // Catch blocks already restored by sibling Yjs ops in this same replay
866
+ // batch — they may be sitting at the top level waiting to be reattached
867
+ // to their original cell. Without this, multi-cell undo restoration
868
+ // leaves cell content as orphan top-level blocks.
869
+ this.cellBlocks?.reclaimReferencedBlocks();
870
+ // Yjs sometimes restores sibling blocks AFTER this sync transaction
871
+ // commits. Schedule a second pass on the next microtask so blocks that
872
+ // arrive late are still attached to their cells.
873
+ void Promise.resolve().then(() => {
874
+ if (currentGeneration !== this.setDataGeneration) {
875
+ return;
876
+ }
877
+ this.cellBlocks?.reclaimReferencedBlocks();
878
+ });
879
+ }
828
880
  }
829
881
 
830
882
  public onPaste(event: HTMLPasteEvent): void {