@jackuait/blok 0.10.10 → 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
  })
@@ -380,6 +380,29 @@ const expandListToHierarchical = (
380
380
  * @param bodyBlocks - legacy body blocks to expand
381
381
  * @param parentId - id of the parent block (toggle/callout) that owns them
382
382
  */
383
+ /**
384
+ * Route one emitted block into the accumulator. Root-level blocks (no parent)
385
+ * become direct children of the legacy container; descendants keep their
386
+ * existing parent refs assigned during recursive expansion.
387
+ */
388
+ const appendEmittedBlock = (
389
+ emitted: OutputBlockData,
390
+ parentId: BlockId,
391
+ childIds: BlockId[],
392
+ childBlocks: OutputBlockData[]
393
+ ): void => {
394
+ if (emitted.parent !== undefined) {
395
+ childBlocks.push(emitted);
396
+
397
+ return;
398
+ }
399
+
400
+ const childId = emitted.id ?? generateBlockId();
401
+
402
+ childIds.push(childId);
403
+ childBlocks.push({ ...emitted, id: childId, parent: parentId });
404
+ };
405
+
383
406
  const expandLegacyBodyBlocks = (
384
407
  bodyBlocks: OutputBlockData[],
385
408
  parentId: BlockId
@@ -390,18 +413,29 @@ const expandLegacyBodyBlocks = (
390
413
  for (const childBlock of bodyBlocks) {
391
414
  const expanded = expandToHierarchical([childBlock]);
392
415
 
393
- if (expanded.length === 0) {
394
- continue;
416
+ // A single legacy block may expand into N root-level siblings (e.g. a list
417
+ // with N items). Every parent-less root becomes a direct child here so
418
+ // multi-item lists inside callout/toggle bodies aren't orphaned.
419
+ for (const emitted of expanded) {
420
+ appendEmittedBlock(emitted, parentId, childIds, childBlocks);
395
421
  }
422
+ }
396
423
 
397
- // The first emitted block corresponds to the original input block.
398
- // Re-parent it to the legacy container; descendants already carry the
399
- // correct parent refs assigned during their own recursive expansion.
400
- const [first, ...rest] = expanded;
401
- const childId = first.id ?? generateBlockId();
402
-
403
- childIds.push(childId);
404
- childBlocks.push({ ...first, id: childId, parent: parentId }, ...rest);
424
+ // Invariant: every emitted block must either carry a parent ref (descendant
425
+ // assigned during its own recursive expansion) or appear in childIds (root
426
+ // we just re-parented). If this ever trips, some expansion path is leaking
427
+ // orphans exactly the regression this assertion exists to catch.
428
+ for (const block of childBlocks) {
429
+ const hasParent = block.parent !== undefined;
430
+ const hasId = block.id !== undefined;
431
+ const isDirectChild = hasId && childIds.includes(block.id as BlockId);
432
+
433
+ if (!hasParent && !isDirectChild) {
434
+ throw new Error(
435
+ `expandLegacyBodyBlocks: orphaned block emitted (type=${block.type}, id=${block.id ?? '<none>'}). ` +
436
+ `Every root-level expansion must be re-parented to ${parentId}.`
437
+ );
438
+ }
405
439
  }
406
440
 
407
441
  return { childIds, childBlocks };
@@ -646,6 +680,52 @@ const processRootListItem = (
646
680
  return listBlock;
647
681
  };
648
682
 
683
+ /**
684
+ * Recursively collapse a container's body: routes each direct child through the
685
+ * appropriate processRoot* helper based on its type so that grandchildren land in
686
+ * the correct nested legacy shape instead of being ejected to the document root.
687
+ */
688
+ function collapseBodyBlocks(
689
+ contentIds: BlockId[],
690
+ blockMap: Map<BlockId, OutputBlockData>,
691
+ processedIds: Set<BlockId>
692
+ ): OutputBlockData[] {
693
+ const result: OutputBlockData[] = [];
694
+
695
+ for (const childId of contentIds) {
696
+ if (processedIds.has(childId)) {
697
+ continue;
698
+ }
699
+ const childBlock = blockMap.get(childId);
700
+
701
+ if (childBlock === undefined) {
702
+ continue;
703
+ }
704
+
705
+ if (isFlatModelListBlock(childBlock)) {
706
+ result.push(processRootListItem(childBlock, blockMap, processedIds));
707
+ continue;
708
+ }
709
+ if (isFlatModelToggleBlock(childBlock)) {
710
+ result.push(processRootToggleItem(childBlock, blockMap, processedIds));
711
+ continue;
712
+ }
713
+ if (isToggleableHeaderBlock(childBlock)) {
714
+ result.push(processRootToggleableHeader(childBlock, blockMap, processedIds));
715
+ continue;
716
+ }
717
+ if (isFlatModelCalloutBlock(childBlock)) {
718
+ result.push(processRootCalloutItem(childBlock, blockMap, processedIds));
719
+ continue;
720
+ }
721
+
722
+ markBlockAsProcessed(childBlock.id, processedIds);
723
+ result.push(stripHierarchyFields(childBlock));
724
+ }
725
+
726
+ return result;
727
+ }
728
+
649
729
  /**
650
730
  * Process a root toggle block and convert to a legacy toggleList block
651
731
  */
@@ -662,18 +742,8 @@ const processRootToggleItem = (
662
742
  ? (data as Record<string, unknown>).isOpen as boolean
663
743
  : undefined;
664
744
 
665
- // Collect child blocks
666
- const childBlocks: OutputBlockData[] = [];
667
745
  const contentIds = block.content ?? [];
668
-
669
- for (const childId of contentIds) {
670
- const childBlock = blockMap.get(childId);
671
-
672
- if (childBlock) {
673
- markBlockAsProcessed(childId, processedIds);
674
- childBlocks.push(stripHierarchyFields(childBlock));
675
- }
676
- }
746
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
677
747
 
678
748
  const legacyBlock: OutputBlockData = {
679
749
  id: block.id,
@@ -732,17 +802,8 @@ const processRootToggleableHeader = (
732
802
  ? (data as Record<string, unknown>).isOpen as boolean
733
803
  : undefined;
734
804
 
735
- const childBlocks: OutputBlockData[] = [];
736
805
  const contentIds = block.content ?? [];
737
-
738
- for (const childId of contentIds) {
739
- const childBlock = blockMap.get(childId);
740
-
741
- if (childBlock) {
742
- markBlockAsProcessed(childId, processedIds);
743
- childBlocks.push(stripHierarchyFields(childBlock));
744
- }
745
- }
806
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
746
807
 
747
808
  const legacyBlock: OutputBlockData = {
748
809
  id: block.id,
@@ -810,18 +871,8 @@ const processRootCalloutItem = (
810
871
  const isEmojiVisible = typeof emojiValue === 'string' && emojiValue.length > 0;
811
872
  const emoji = isEmojiVisible ? emojiValue : null;
812
873
 
813
- // Collect child blocks
814
- const childBlocks: OutputBlockData[] = [];
815
874
  const contentIds = block.content ?? [];
816
-
817
- for (const childId of contentIds) {
818
- const childBlock = blockMap.get(childId);
819
-
820
- if (childBlock) {
821
- markBlockAsProcessed(childId, processedIds);
822
- childBlocks.push(stripHierarchyFields(childBlock));
823
- }
824
- }
875
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
825
876
 
826
877
  const legacyBlock: OutputBlockData = {
827
878
  id: block.id,
@@ -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;