@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.
@@ -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
 
@@ -10,7 +10,7 @@ import type { ListItemConfig, ListItemData, ListItemStyle } from './types';
10
10
  * Type for legacy list item format (used for type guard)
11
11
  */
12
12
  type LegacyListItemFormat = {
13
- items: Array<{ content: string; checked?: boolean | string }>;
13
+ items: Array<string | { content?: string; text?: string; checked?: boolean | string }>;
14
14
  style?: ListItemStyle;
15
15
  start?: number;
16
16
  };
@@ -117,8 +117,17 @@ export const normalizeListItemData = (
117
117
  // This provides backward compatibility when legacy data is passed directly to the tool
118
118
  if (isLegacyFormat(data)) {
119
119
  const firstItem = data.items[0];
120
- const text = firstItem?.content || '';
121
- const checked = firstItem?.checked || false;
120
+ // handle string items and old {text,checked} shape
121
+ const extractLegacy = (item: typeof firstItem): { text: string; checked: boolean | string | undefined } => {
122
+ if (typeof item === 'string') {
123
+ return { text: item, checked: false };
124
+ }
125
+ if (item !== null && typeof item === 'object') {
126
+ return { text: item.content ?? item.text ?? '', checked: item.checked ?? false };
127
+ }
128
+ return { text: '', checked: false };
129
+ };
130
+ const { text, checked } = extractLegacy(firstItem);
122
131
 
123
132
  return {
124
133
  text,
@@ -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 {
@@ -1,7 +1,7 @@
1
1
  import type { API } from '../../../types';
2
2
  import { DATA_ATTR } from '../../components/constants/data-attributes';
3
3
 
4
- import { CELL_ATTR, ROW_ATTR, CELL_COL_ATTR } from './table-core';
4
+ import { CELL_ATTR, ROW_ATTR, CELL_ROW_ATTR, CELL_COL_ATTR } from './table-core';
5
5
  import type { TableModel } from './table-model';
6
6
  import type { LegacyCellContent, CellContent } from './types';
7
7
  import { isCellWithBlocks } from './types';
@@ -353,7 +353,9 @@ export class TableCellBlocks {
353
353
  * - Cells that already have block references get those blocks mounted.
354
354
  * - If referenced blocks are missing from BlockManager, a fallback paragraph is created.
355
355
  */
356
- public initializeCells(content: LegacyCellContent[][]): CellContent[][] {
356
+ public initializeCells(
357
+ content: LegacyCellContent[][]
358
+ ): CellContent[][] {
357
359
  const rowElements = this.gridElement.querySelectorAll(`[${ROW_ATTR}]`);
358
360
  const normalizedContent: CellContent[][] = [];
359
361
 
@@ -437,6 +439,63 @@ export class TableCellBlocks {
437
439
  return normalizedContent;
438
440
  }
439
441
 
442
+ /**
443
+ * After a setData/render rebuild, reclaim any blocks the model references
444
+ * whose holders are not yet mounted in their model cell. This catches blocks
445
+ * that were restored via separate Yjs ops in a different transaction order
446
+ * — without it the restored block would float at the top level as an orphan
447
+ * (regression: table-undo-redo-orphans, multi-cell undo restoration).
448
+ */
449
+ public reclaimReferencedBlocks(): void {
450
+ const snapshot = this.model.snapshot();
451
+
452
+ snapshot.content.forEach((row, rowIndex) => {
453
+ row.forEach((cellContent, colIndex) => {
454
+ if (!isCellWithBlocks(cellContent) || cellContent.blocks.length === 0) {
455
+ return;
456
+ }
457
+
458
+ const cell = this.gridElement.querySelector<HTMLElement>(
459
+ `[${CELL_ROW_ATTR}="${rowIndex}"][${CELL_COL_ATTR}="${colIndex}"]`
460
+ );
461
+
462
+ if (!cell) {
463
+ return;
464
+ }
465
+
466
+ const container = cell.querySelector<HTMLElement>(`[${CELL_BLOCKS_ATTR}]`);
467
+
468
+ if (!container) {
469
+ return;
470
+ }
471
+
472
+ for (const blockId of cellContent.blocks) {
473
+ const getIndex = this.api.blocks.getBlockIndex;
474
+ const getByIndex = this.api.blocks.getBlockByIndex;
475
+
476
+ if (typeof getIndex !== 'function' || typeof getByIndex !== 'function') {
477
+ return;
478
+ }
479
+
480
+ const index = getIndex(blockId);
481
+
482
+ if (index === undefined) {
483
+ continue;
484
+ }
485
+ const block = getByIndex(index);
486
+
487
+ if (!block) {
488
+ continue;
489
+ }
490
+ if (container.contains(block.holder)) {
491
+ continue;
492
+ }
493
+ this.claimBlockForCell(cell, blockId);
494
+ }
495
+ });
496
+ });
497
+ }
498
+
440
499
  /**
441
500
  * Remove placeholder attributes from contenteditable elements inside a cell container.
442
501
  * Blocks in table cells should feel like plain table fields, not standalone paragraphs.
@@ -635,7 +694,14 @@ export class TableCellBlocks {
635
694
  * When a block is removed, ensure no cell is left empty.
636
695
  */
637
696
  private handleBlockMutation = (data: unknown): void => {
638
- if (this.isStructuralOpActive()) {
697
+ // While a structural op (setData / paste / row-col change) is rebuilding
698
+ // the table, defer events so they don't operate on stale DOM. EXCEPT for
699
+ // Yjs replay: an undo/redo that restores a previously-owned cell block
700
+ // fires block-added DURING the table's own setData, and discarding it
701
+ // would leave the restored block as a top-level orphan
702
+ // (regression: table-undo-redo-orphans). Process those immediately —
703
+ // recordedCellPos lookup below will route the block back to its cell.
704
+ if (this.isStructuralOpActive() && !this.api.blocks.isSyncingFromYjs) {
639
705
  this.deferredEvents.push(data);
640
706
 
641
707
  return;
@@ -674,6 +740,27 @@ export class TableCellBlocks {
674
740
  return;
675
741
  }
676
742
 
743
+ // Yjs undo replay: a block this table previously owned is being restored.
744
+ // The model's contentGrid still references its id from a prior render but
745
+ // the DOM is empty (we deliberately did not fabricate a replacement, see
746
+ // table-undo-redo-orphans regression). Reattach it to the recorded cell
747
+ // before falling through to adjacency-based heuristics, otherwise the
748
+ // restored block lands as a top-level orphan.
749
+ const recordedCellPos = this.model.findCellForBlock(detail.target.id);
750
+
751
+ if (recordedCellPos) {
752
+ const cellEl = this.gridElement.querySelector<HTMLElement>(
753
+ `[${CELL_ROW_ATTR}="${recordedCellPos.row}"][${CELL_COL_ATTR}="${recordedCellPos.col}"]`
754
+ );
755
+
756
+ if (cellEl) {
757
+ this.claimBlockForCell(cellEl, detail.target.id);
758
+ this.cellsPendingCheck.delete(cellEl);
759
+
760
+ return;
761
+ }
762
+ }
763
+
677
764
  const blockIndex = detail.index;
678
765
 
679
766
  if (blockIndex === undefined) {
@@ -189,9 +189,10 @@ export interface Blocks {
189
189
  *
190
190
  * @param parentId - id of the parent block
191
191
  * @param insertIndex - flat block index where the new block should appear
192
+ * @param childData - optional data override for the child block (default: empty paragraph)
192
193
  * @returns BlockAPI for the newly created child block
193
194
  */
194
- insertInsideParent(parentId: string, insertIndex: number): BlockAPI;
195
+ insertInsideParent(parentId: string, insertIndex: number, childData?: BlockToolData): BlockAPI;
195
196
 
196
197
  /**
197
198
  * Execute a function within a transaction.