@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
@@ -102,6 +102,15 @@ export class UndoHistory {
102
102
  */
103
103
  private moveCallback: (blockId: string, toIndex: number, origin: 'local' | 'move-undo' | 'move-redo') => void;
104
104
 
105
+ /**
106
+ * Callback to restore a block's parent during move-undo/move-redo.
107
+ *
108
+ * Must not record its own history entry — the call is part of replaying
109
+ * an existing `SingleMoveEntry`. Set by YjsManager to route through
110
+ * `transactWithoutCapture` + a direct in-memory reparent.
111
+ */
112
+ private parentRestoreCallback: (blockId: string, parentId: string | null) => void;
113
+
105
114
  constructor(
106
115
  yblocks: Y.Array<Y.Map<unknown>>,
107
116
  blok: BlokModules
@@ -120,6 +129,9 @@ export class UndoHistory {
120
129
  this.moveCallback = () => {
121
130
  // Placeholder, will be set by setMoveCallback
122
131
  };
132
+ this.parentRestoreCallback = () => {
133
+ // Placeholder, will be set by setParentRestoreCallback
134
+ };
123
135
  }
124
136
 
125
137
  /**
@@ -131,6 +143,16 @@ export class UndoHistory {
131
143
  this.moveCallback = callback;
132
144
  }
133
145
 
146
+ /**
147
+ * Set the parent-restore callback used by move-undo/move-redo to rewind
148
+ * drag-reparent side effects. See `parentRestoreCallback`.
149
+ */
150
+ public setParentRestoreCallback(
151
+ callback: (blockId: string, parentId: string | null) => void
152
+ ): void {
153
+ this.parentRestoreCallback = callback;
154
+ }
155
+
134
156
  /**
135
157
  * Set the Blok modules. Called when Blok modules are initialized.
136
158
  */
@@ -216,10 +238,14 @@ export class UndoHistory {
216
238
  // Push to redo stack for potential redo
217
239
  this.moveRedoStack.push(lastMoveGroup);
218
240
 
219
- // Reverse all moves in the group, in reverse order
220
- // This is crucial for multi-block moves to restore correctly
241
+ // Reverse all moves in the group, in reverse order.
242
+ // This is crucial for multi-block moves to restore correctly.
243
+ //
244
+ // Drag-reparent entries may additionally carry `fromParentId`; restore
245
+ // the parent BEFORE the position so the block lands in the correct
246
+ // flat-array slot relative to its (soon-to-be-restored) parent siblings.
221
247
  [...lastMoveGroup].reverse().forEach((move) => {
222
- this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
248
+ this.replayMoveUndo(move);
223
249
  });
224
250
 
225
251
  // Pop caret entry only after move succeeds
@@ -257,9 +283,12 @@ export class UndoHistory {
257
283
  // Push back to undo stack
258
284
  this.moveUndoStack.push(lastMoveGroup);
259
285
 
260
- // Redo all moves in the group, in original order
286
+ // Redo all moves in the group, in original order. Drag-reparent
287
+ // entries restore the destination parent AFTER the position so that
288
+ // the flat-array splice settles first and the parent's contentIds
289
+ // then re-attach cleanly.
261
290
  for (const move of lastMoveGroup) {
262
- this.moveCallback(move.blockId, move.toIndex, 'move-redo');
291
+ this.replayMoveRedo(move);
263
292
  }
264
293
 
265
294
  // Pop caret entry only after move succeeds
@@ -307,6 +336,36 @@ export class UndoHistory {
307
336
  this.restoreCaretSnapshot(snapshot);
308
337
  }
309
338
 
339
+ /**
340
+ * Replay a single move entry in the undo direction.
341
+ * Parent restore runs BEFORE the position restore so the block lands in
342
+ * the correct slot relative to its (soon-to-be-restored) parent siblings.
343
+ */
344
+ private replayMoveUndo(move: SingleMoveEntry): void {
345
+ if (move.fromParentId !== undefined) {
346
+ this.parentRestoreCallback(move.blockId, move.fromParentId);
347
+ }
348
+
349
+ if (move.fromIndex !== -1) {
350
+ this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Replay a single move entry in the redo direction.
356
+ * Position restore runs BEFORE the parent restore so the flat-array splice
357
+ * settles first and the destination parent's contentIds re-attach cleanly.
358
+ */
359
+ private replayMoveRedo(move: SingleMoveEntry): void {
360
+ if (move.toIndex !== -1) {
361
+ this.moveCallback(move.blockId, move.toIndex, 'move-redo');
362
+ }
363
+
364
+ if (move.toParentId !== undefined) {
365
+ this.parentRestoreCallback(move.blockId, move.toParentId);
366
+ }
367
+ }
368
+
310
369
  /**
311
370
  * Execute a Yjs UndoManager operation with the isPerformingUndoRedo flag set.
312
371
  * This prevents the stack-item-added listener from modifying caret stacks during
@@ -438,6 +497,58 @@ export class UndoHistory {
438
497
  }
439
498
  }
440
499
 
500
+ /**
501
+ * Attach a parent change to the in-flight move entry (or create a
502
+ * parent-only entry if the block hasn't been moved inside the group yet).
503
+ *
504
+ * Used by drag-reparent so that `undo` restores the parent relationship
505
+ * atomically with the array move. The caller (`BlockManager.setBlockParent`
506
+ * when `YjsManager.isInMoveGroup` is true) is responsible for writing the
507
+ * parentId/contentIds to Yjs through `transactWithoutCapture` so the
508
+ * Y.UndoManager does not also record the change.
509
+ * @param blockId - id of the reparented block
510
+ * @param fromParentId - parent id before the reparent (null for root)
511
+ * @param toParentId - parent id after the reparent (null for root)
512
+ */
513
+ public recordParentChangeForPendingMove(
514
+ blockId: string,
515
+ fromParentId: string | null,
516
+ toParentId: string | null
517
+ ): void {
518
+ if (this.pendingMoveGroup === null) {
519
+ // Not inside a move group — nothing to attach to. Drop the hint.
520
+ return;
521
+ }
522
+
523
+ const existing = this.pendingMoveGroup.find(
524
+ entry => entry.blockId === blockId
525
+ );
526
+
527
+ if (existing !== undefined) {
528
+ // Preserve the earliest known `fromParentId` (first write wins — that's
529
+ // the parent BEFORE the drag started). Always update `toParentId` to
530
+ // the most recent write.
531
+ if (existing.fromParentId === undefined) {
532
+ existing.fromParentId = fromParentId;
533
+ }
534
+ existing.toParentId = toParentId;
535
+
536
+ return;
537
+ }
538
+
539
+ // No matching move entry yet (e.g. a same-index reparent within a toggle
540
+ // body, where DragController calls setBlockParent without a prior move).
541
+ // Push a parent-only entry with identical from/to indices so the undo
542
+ // walker still has something to unwind.
543
+ this.pendingMoveGroup.push({
544
+ blockId,
545
+ fromIndex: -1,
546
+ toIndex: -1,
547
+ fromParentId,
548
+ toParentId,
549
+ });
550
+ }
551
+
441
552
  /**
442
553
  * Capture the current caret position as a snapshot.
443
554
  * @returns CaretSnapshot or null if no block is focused
@@ -368,6 +368,45 @@ const expandListToHierarchical = (
368
368
  return blocks;
369
369
  };
370
370
 
371
+ /**
372
+ * Recursively expand a list of legacy body blocks into hierarchical flat blocks.
373
+ * Each body block is routed through expandToHierarchical so that nested legacy
374
+ * toggleList/callout/list structures are fully flattened instead of passing
375
+ * through with their legacy type (which would hit Renderer's "unknown tool"
376
+ * fallback and render as a stub).
377
+ *
378
+ * Returns both the direct-child IDs (for the parent's `content` array) and
379
+ * the flattened descendant blocks in document order.
380
+ * @param bodyBlocks - legacy body blocks to expand
381
+ * @param parentId - id of the parent block (toggle/callout) that owns them
382
+ */
383
+ const expandLegacyBodyBlocks = (
384
+ bodyBlocks: OutputBlockData[],
385
+ parentId: BlockId
386
+ ): { childIds: BlockId[]; childBlocks: OutputBlockData[] } => {
387
+ const childIds: BlockId[] = [];
388
+ const childBlocks: OutputBlockData[] = [];
389
+
390
+ for (const childBlock of bodyBlocks) {
391
+ const expanded = expandToHierarchical([childBlock]);
392
+
393
+ if (expanded.length === 0) {
394
+ continue;
395
+ }
396
+
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);
405
+ }
406
+
407
+ return { childIds, childBlocks };
408
+ };
409
+
371
410
  /**
372
411
  * Expand a legacy toggleList block into flat toggle block + child blocks
373
412
  */
@@ -378,20 +417,7 @@ const expandToggleListToHierarchical = (
378
417
  const toggleId = block.id ?? generateBlockId();
379
418
  const bodyBlocks = block.data.body?.blocks ?? [];
380
419
 
381
- // Collect child IDs, ensuring each child has an ID
382
- const childIds: BlockId[] = [];
383
- const childBlocks: OutputBlockData[] = [];
384
-
385
- for (const childBlock of bodyBlocks) {
386
- const childId = childBlock.id ?? generateBlockId();
387
-
388
- childIds.push(childId);
389
- childBlocks.push({
390
- ...childBlock,
391
- id: childId,
392
- parent: toggleId,
393
- });
394
- }
420
+ const { childIds, childBlocks } = expandLegacyBodyBlocks(bodyBlocks, toggleId);
395
421
 
396
422
  const sharedFields = {
397
423
  id: toggleId,
@@ -439,20 +465,7 @@ const expandCalloutToHierarchical = (
439
465
  const calloutId = block.id ?? generateBlockId();
440
466
  const bodyBlocks = block.data.body?.blocks ?? [];
441
467
 
442
- // Collect child IDs, ensuring each child has an ID
443
- const childIds: BlockId[] = [];
444
- const childBlocks: OutputBlockData[] = [];
445
-
446
- for (const childBlock of bodyBlocks) {
447
- const childId = childBlock.id ?? generateBlockId();
448
-
449
- childIds.push(childId);
450
- childBlocks.push({
451
- ...childBlock,
452
- id: childId,
453
- parent: calloutId,
454
- });
455
- }
468
+ const { childIds, childBlocks } = expandLegacyBodyBlocks(bodyBlocks, calloutId);
456
469
 
457
470
  // Map variant → backgroundColor preset
458
471
  const variant = block.data.variant ?? 'general';
@@ -831,31 +844,105 @@ const processRootCalloutItem = (
831
844
  * @param blocks - array of flat blocks with parent/content references
832
845
  * @returns collapsed array with nested structures
833
846
  */
847
+ /**
848
+ * Groups one block under its parent in the derived-content map if it has a
849
+ * valid parent reference. Helper extracted for collapseToLegacy reconciliation.
850
+ */
851
+ const appendChildToDerivedContent = (
852
+ block: OutputBlockData,
853
+ blockById: Map<BlockId, OutputBlockData>,
854
+ derivedContent: Map<BlockId, BlockId[]>
855
+ ): void => {
856
+ if (!block.id || !block.parent || !blockById.has(block.parent)) {
857
+ return;
858
+ }
859
+ const siblings = derivedContent.get(block.parent);
860
+
861
+ if (siblings === undefined) {
862
+ derivedContent.set(block.parent, [block.id]);
863
+
864
+ return;
865
+ }
866
+ siblings.push(block.id);
867
+ };
868
+
869
+ /**
870
+ * Merges live (parent-derived) ids into the existing content[] preserving its
871
+ * order, dropping any dead ids that don't resolve to a block in the input.
872
+ */
873
+ const mergeContentIds = (
874
+ existingContent: BlockId[] | undefined,
875
+ derivedIds: BlockId[],
876
+ blockById: Map<BlockId, OutputBlockData>
877
+ ): BlockId[] => {
878
+ const existing = Array.isArray(existingContent) ? existingContent : [];
879
+ const merged = existing.filter((id) => blockById.has(id));
880
+
881
+ for (const id of derivedIds) {
882
+ if (!merged.includes(id)) {
883
+ merged.push(id);
884
+ }
885
+ }
886
+
887
+ return merged;
888
+ };
889
+
834
890
  export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] => {
891
+ // Defense-in-depth: reconcile each parent's content[] from children's parent
892
+ // fields before processing. Saver is the primary source of truth for content[]
893
+ // (see src/components/modules/saver.ts#doSave), but this pass guarantees the
894
+ // invariant `child.parent === X ⇒ X.content.includes(child.id)` even when
895
+ // OutputBlockData originates from a path that bypassed the saver — migrations,
896
+ // external JSON, tests, 3rd-party consumers. Without this, stale content[]
897
+ // causes processRootCalloutItem to eject real children as root siblings.
898
+ const reconciledBlocks = blocks.map((block) => ({ ...block }));
899
+ const reconciledById = new Map<BlockId, OutputBlockData>();
900
+
901
+ for (const block of reconciledBlocks) {
902
+ if (block.id) {
903
+ reconciledById.set(block.id, block);
904
+ }
905
+ }
906
+
907
+ const derivedContent = new Map<BlockId, BlockId[]>();
908
+
909
+ for (const block of reconciledBlocks) {
910
+ appendChildToDerivedContent(block, reconciledById, derivedContent);
911
+ }
912
+
913
+ for (const [parentId, derivedIds] of derivedContent) {
914
+ const parent = reconciledById.get(parentId);
915
+
916
+ if (parent === undefined) {
917
+ continue;
918
+ }
919
+ parent.content = mergeContentIds(parent.content, derivedIds, reconciledById);
920
+ }
921
+
835
922
  // Build a map of blocks by ID for quick lookup
836
923
  const blockMap = new Map<BlockId, OutputBlockData>();
837
924
 
838
- for (const block of blocks) {
925
+ for (const block of reconciledBlocks) {
839
926
  if (block.id) {
840
927
  blockMap.set(block.id, block);
841
928
  }
842
929
  }
843
930
 
844
931
  // If no flat-model list, toggle, or callout blocks, just strip hierarchy fields and return
845
- const hasFlatListBlocks = blocks.some(isFlatModelListBlock);
846
- const hasFlatToggleBlocks = blocks.some(isFlatModelToggleBlock);
847
- const hasFlatToggleableHeaders = blocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
848
- const hasFlatCalloutBlocks = blocks.some(isFlatModelCalloutBlock);
932
+ const hasFlatListBlocks = reconciledBlocks.some(isFlatModelListBlock);
933
+ const hasFlatToggleBlocks = reconciledBlocks.some(isFlatModelToggleBlock);
934
+ const hasFlatToggleableHeaders = reconciledBlocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
935
+ const hasFlatCalloutBlocks = reconciledBlocks.some(isFlatModelCalloutBlock);
849
936
 
850
937
  if (!hasFlatListBlocks && !hasFlatToggleBlocks && !hasFlatToggleableHeaders && !hasFlatCalloutBlocks) {
851
- return blocks.map(stripHierarchyFields);
938
+ return reconciledBlocks.map(stripHierarchyFields);
852
939
  }
853
940
 
854
941
  // Process blocks, converting root flat-model list blocks to legacy List blocks
855
942
  const result: OutputBlockData[] = [];
856
943
  const processedIds = new Set<BlockId>();
857
944
 
858
- for (const block of blocks) {
945
+ for (const block of reconciledBlocks) {
859
946
  const alreadyProcessed = block.id && processedIds.has(block.id);
860
947
 
861
948
  if (alreadyProcessed) {
@@ -905,6 +992,131 @@ export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] =
905
992
  return result;
906
993
  };
907
994
 
995
+ /**
996
+ * A table cell that references its content blocks by id.
997
+ * Tables persist their child blocks via `data.content[row][col].blocks = [<id>, ...]`
998
+ * rather than nesting block payloads inline, so the parent/content relationship
999
+ * is implicit in the table data instead of explicit on each child block.
1000
+ */
1001
+ interface CellWithBlockRefs {
1002
+ blocks: string[];
1003
+ }
1004
+
1005
+ const isCellWithBlockRefs = (cell: unknown): cell is CellWithBlockRefs => {
1006
+ return (
1007
+ typeof cell === 'object' &&
1008
+ cell !== null &&
1009
+ Array.isArray((cell as { blocks?: unknown }).blocks)
1010
+ );
1011
+ };
1012
+
1013
+ interface TableDataShape {
1014
+ content?: unknown;
1015
+ }
1016
+
1017
+ const getTableContentRows = (data: unknown): unknown[][] | null => {
1018
+ if (typeof data !== 'object' || data === null) {
1019
+ return null;
1020
+ }
1021
+ const content = (data as TableDataShape).content;
1022
+
1023
+ if (!Array.isArray(content)) {
1024
+ return null;
1025
+ }
1026
+ return content as unknown[][];
1027
+ };
1028
+
1029
+ /**
1030
+ * When a flat block array contains `table` blocks that reference child blocks
1031
+ * via `data.content[row][col].blocks = [<id>, ...]`, ensure each referenced
1032
+ * child carries `parent: <tableId>`. This makes the parent/content invariant
1033
+ * explicit even for externally-authored data shapes that omit the `parent`
1034
+ * field on children.
1035
+ *
1036
+ * Without this normalization, downstream readers that key on parentId
1037
+ * (`mountCellBlocksReadOnly`'s cross-table guard, the table saver's
1038
+ * own-child filter, hierarchy queries, drag-and-drop) skip those children
1039
+ * and leak them out of the table, rendering them at the bottom of the page
1040
+ * instead of inside the cells.
1041
+ *
1042
+ * The function is idempotent, never mutates the input array, and leaves
1043
+ * pre-existing `parent` fields unchanged. Children referenced by multiple
1044
+ * tables get assigned to the first table that lists them (first-writer-wins);
1045
+ * corrupted cross-table references are preserved as-is so defensive guards
1046
+ * downstream can still reject them.
1047
+ * @param blocks - flat block array potentially containing tables with cell refs
1048
+ */
1049
+ const collectCellChildRefs = (
1050
+ cell: unknown,
1051
+ tableId: BlockId,
1052
+ childToTable: Map<BlockId, BlockId>
1053
+ ): void => {
1054
+ if (!isCellWithBlockRefs(cell)) {
1055
+ return;
1056
+ }
1057
+ for (const childId of cell.blocks) {
1058
+ if (typeof childId !== 'string' || childToTable.has(childId)) {
1059
+ continue;
1060
+ }
1061
+ childToTable.set(childId, tableId);
1062
+ }
1063
+ };
1064
+
1065
+ const collectRowChildRefs = (
1066
+ row: unknown,
1067
+ tableId: BlockId,
1068
+ childToTable: Map<BlockId, BlockId>
1069
+ ): void => {
1070
+ if (!Array.isArray(row)) {
1071
+ return;
1072
+ }
1073
+ row.forEach(cell => collectCellChildRefs(cell, tableId, childToTable));
1074
+ };
1075
+
1076
+ const collectTableChildRefs = (
1077
+ tableBlock: OutputBlockData,
1078
+ childToTable: Map<BlockId, BlockId>
1079
+ ): void => {
1080
+ if (tableBlock.id === undefined || tableBlock.id === null) {
1081
+ return;
1082
+ }
1083
+ const rows = getTableContentRows(tableBlock.data);
1084
+
1085
+ if (rows === null) {
1086
+ return;
1087
+ }
1088
+ const tableId = tableBlock.id;
1089
+
1090
+ rows.forEach(row => collectRowChildRefs(row, tableId, childToTable));
1091
+ };
1092
+
1093
+ export const normalizeTableChildParents = (blocks: OutputBlockData[]): OutputBlockData[] => {
1094
+ const childToTable = new Map<BlockId, BlockId>();
1095
+
1096
+ blocks
1097
+ .filter(block => block.type === 'table')
1098
+ .forEach(tableBlock => collectTableChildRefs(tableBlock, childToTable));
1099
+
1100
+ if (childToTable.size === 0) {
1101
+ return blocks;
1102
+ }
1103
+
1104
+ return blocks.map(block => {
1105
+ if (block.id === undefined || block.id === null) {
1106
+ return block;
1107
+ }
1108
+ const tableId = childToTable.get(block.id);
1109
+
1110
+ if (tableId === undefined) {
1111
+ return block;
1112
+ }
1113
+ if (block.parent !== undefined && block.parent !== null) {
1114
+ return block;
1115
+ }
1116
+ return { ...block, parent: tableId };
1117
+ });
1118
+ };
1119
+
908
1120
  /**
909
1121
  * Check if transformation is needed based on config and detected format
910
1122
  */
@@ -0,0 +1,137 @@
1
+ import type { OutputBlockData } from '@/types';
2
+ import type { BlockId } from '../../../types/data-formats/block-id';
3
+
4
+ /**
5
+ * Hierarchy invariant validator.
6
+ *
7
+ * Every block with `parent: X` must appear in `X.content`, and every id in a
8
+ * block's `content[]` must resolve to a block whose `parent` points back. Any
9
+ * drift between the two representations is the signature of the callout paste
10
+ * ejection bug (and its siblings across toggle, toggleable header, list, and
11
+ * any future container block).
12
+ *
13
+ * This util exists so tests and saver-level assertions can detect drift at
14
+ * any point in the pipeline — load, save, collapse, or post-mutation — without
15
+ * hand-rolling the same loop. Treat it as the single source of truth for the
16
+ * parent/content invariant.
17
+ */
18
+
19
+ export interface HierarchyViolation {
20
+ kind:
21
+ | 'child-parent-missing'
22
+ | 'child-not-in-parent-content'
23
+ | 'content-id-dangling'
24
+ | 'content-parent-mismatch'
25
+ | 'content-duplicate';
26
+ blockId: BlockId | undefined;
27
+ parentId?: BlockId;
28
+ childId?: BlockId;
29
+ message: string;
30
+ }
31
+
32
+ const pushViolation = (violations: HierarchyViolation[], v: HierarchyViolation): void => {
33
+ violations.push(v);
34
+ };
35
+
36
+ const checkParentLinks = (
37
+ block: OutputBlockData,
38
+ blockById: Map<BlockId, OutputBlockData>,
39
+ violations: HierarchyViolation[]
40
+ ): void => {
41
+ if (block.parent === undefined || block.parent === null) {
42
+ return;
43
+ }
44
+ const parent = blockById.get(block.parent);
45
+
46
+ if (parent === undefined) {
47
+ pushViolation(violations, {
48
+ kind: 'child-parent-missing',
49
+ blockId: block.id,
50
+ parentId: block.parent,
51
+ message: `Block ${String(block.id)} references missing parent ${String(block.parent)}`,
52
+ });
53
+
54
+ return;
55
+ }
56
+ if (block.id === undefined || !Array.isArray(parent.content) || !parent.content.includes(block.id)) {
57
+ pushViolation(violations, {
58
+ kind: 'child-not-in-parent-content',
59
+ blockId: block.id,
60
+ parentId: block.parent,
61
+ message: `Block ${String(block.id)} has parent=${String(block.parent)} but that parent's content[] does not include it`,
62
+ });
63
+ }
64
+ };
65
+
66
+ const checkContentArray = (
67
+ block: OutputBlockData,
68
+ blockById: Map<BlockId, OutputBlockData>,
69
+ violations: HierarchyViolation[]
70
+ ): void => {
71
+ if (!Array.isArray(block.content)) {
72
+ return;
73
+ }
74
+ const seen = new Set<BlockId>();
75
+
76
+ for (const childId of block.content) {
77
+ if (seen.has(childId)) {
78
+ pushViolation(violations, {
79
+ kind: 'content-duplicate',
80
+ blockId: block.id,
81
+ childId,
82
+ message: `Block ${String(block.id)}.content[] contains duplicate id ${String(childId)}`,
83
+ });
84
+ continue;
85
+ }
86
+ seen.add(childId);
87
+
88
+ const child = blockById.get(childId);
89
+
90
+ if (child === undefined) {
91
+ pushViolation(violations, {
92
+ kind: 'content-id-dangling',
93
+ blockId: block.id,
94
+ childId,
95
+ message: `Block ${String(block.id)}.content[] references missing child ${String(childId)}`,
96
+ });
97
+ continue;
98
+ }
99
+ if (child.parent !== block.id) {
100
+ pushViolation(violations, {
101
+ kind: 'content-parent-mismatch',
102
+ blockId: block.id,
103
+ childId,
104
+ message: `Block ${String(block.id)}.content[] includes ${String(childId)} but that child's parent is ${String(child.parent)}`,
105
+ });
106
+ }
107
+ }
108
+ };
109
+
110
+ export const validateHierarchy = (blocks: OutputBlockData[]): HierarchyViolation[] => {
111
+ const violations: HierarchyViolation[] = [];
112
+ const blockById = new Map<BlockId, OutputBlockData>();
113
+
114
+ for (const block of blocks) {
115
+ if (block.id !== undefined) {
116
+ blockById.set(block.id, block);
117
+ }
118
+ }
119
+
120
+ for (const block of blocks) {
121
+ checkParentLinks(block, blockById, violations);
122
+ checkContentArray(block, blockById, violations);
123
+ }
124
+
125
+ return violations;
126
+ };
127
+
128
+ export const assertHierarchy = (blocks: OutputBlockData[], context: string): void => {
129
+ const violations = validateHierarchy(blocks);
130
+
131
+ if (violations.length === 0) {
132
+ return;
133
+ }
134
+ const summary = violations.map(v => ` - ${v.message}`).join('\n');
135
+
136
+ throw new Error(`Hierarchy invariant violated at ${context}:\n${summary}`);
137
+ };
@@ -6,6 +6,7 @@ import type { HandlerContext } from '../components/modules/paste/types';
6
6
  import type { PasteHandler } from '../components/modules/paste/handlers/base';
7
7
  import { BasePasteHandler } from '../components/modules/paste/handlers/base';
8
8
  import { Block } from '../components/block';
9
+ import { normalizeTableChildParents } from '../components/utils/data-model-transform';
9
10
 
10
11
  /**
11
12
  * Patterns that indicate text is likely Markdown rather than plain text.
@@ -66,12 +67,18 @@ export class MarkdownHandler extends BasePasteHandler implements PasteHandler {
66
67
  }
67
68
 
68
69
  const { markdownToBlocks } = await import('./index');
69
- const outputBlocks = await markdownToBlocks(data);
70
+ const rawOutputBlocks = await markdownToBlocks(data);
70
71
 
71
- if (!outputBlocks.length) {
72
+ if (!rawOutputBlocks.length) {
72
73
  return false;
73
74
  }
74
75
 
76
+ // Defense-in-depth: backfill `parent` on table cell children so that any
77
+ // future regression in mdast-to-blocks (or external converter) cannot
78
+ // produce the dodopizza shape (children referenced by table cells but
79
+ // lacking explicit parent), which would render them at page bottom.
80
+ const outputBlocks = normalizeTableChildParents(rawOutputBlocks);
81
+
75
82
  const { BlockManager, Caret } = this.Blok;
76
83
 
77
84
  // Replace empty default block if present
@@ -1306,6 +1306,11 @@
1306
1306
  @apply p-0 m-0 min-h-[1.6em];
1307
1307
  }
1308
1308
 
1309
+ /* List items inside table cells use tight 2px top/bottom spacing */
1310
+ [data-blok-table-cell-blocks] [data-blok-tool="list"] {
1311
+ @apply py-0 mt-[2px] mb-[2px];
1312
+ }
1313
+
1309
1314
  /* ─── Cell content placement ──────────────────────────────────── */
1310
1315
 
1311
1316
  [data-blok-cell-placement="top-center"] {
@@ -29,4 +29,3 @@ export const WRAPPER_STYLES = 'rounded-xl pl-8 pr-4 py-[5px] my-1 flex items-sta
29
29
  // h-[38px] = py-[7px]×2 + 1.5rem×1 = 14+24; explicit height prevents platform-specific emoji font metrics from inflating the button
30
30
  export const EMOJI_BUTTON_STYLES = 'text-[1.5rem] leading-[1] cursor-pointer bg-transparent border-0 px-0 py-[7px] h-[38px] flex-shrink-0 select-none';
31
31
  export const CHILDREN_STYLES = 'flex-1 min-w-0';
32
- export const DRAG_ZONE_STYLES = 'absolute left-0 top-0 h-full cursor-grab select-none';