@jackuait/blok 0.10.0-beta.9 → 0.10.0

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 (59) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-DDu252IK.mjs → blok-BfcBwAfE.mjs} +1211 -1159
  3. package/dist/chunks/{constants-DMW9a31I.mjs → constants-QNVyXALL.mjs} +49 -48
  4. package/dist/chunks/{tools-XmzH2rgQ.mjs → tools-DHtzbrxy.mjs} +1403 -1220
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +3 -5
  9. package/src/cli/commands/convert-gdocs/index.ts +26 -0
  10. package/src/cli/commands/convert-html/block-builder.ts +392 -0
  11. package/src/cli/commands/convert-html/id-generator.ts +11 -0
  12. package/src/cli/commands/convert-html/index.ts +23 -0
  13. package/src/cli/commands/convert-html/preprocessor.ts +422 -0
  14. package/src/cli/commands/convert-html/sanitizer.ts +93 -0
  15. package/src/cli/commands/convert-html/types.ts +15 -0
  16. package/src/cli/index.ts +56 -5
  17. package/src/components/block/index.ts +44 -10
  18. package/src/components/constants/data-attributes.ts +10 -0
  19. package/src/components/icons/index.ts +16 -0
  20. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +18 -0
  21. package/src/components/modules/blockManager/hierarchy.ts +4 -1
  22. package/src/components/modules/readonly.ts +46 -0
  23. package/src/components/modules/rectangleSelection.ts +25 -5
  24. package/src/components/modules/toolbar/index.ts +96 -19
  25. package/src/components/modules/toolbar/styles.ts +0 -2
  26. package/src/components/modules/uiControllers/controllers/blockHover.ts +44 -1
  27. package/src/components/tools/block.ts +10 -0
  28. package/src/components/utils/placeholder.ts +9 -2
  29. package/src/styles/main.css +16 -0
  30. package/src/tools/callout/constants.ts +2 -1
  31. package/src/tools/callout/dom-builder.ts +13 -1
  32. package/src/tools/callout/index.ts +21 -7
  33. package/src/tools/code/constants.ts +9 -1
  34. package/src/tools/code/dom-builder.ts +90 -54
  35. package/src/tools/code/index.ts +73 -31
  36. package/src/tools/divider/index.ts +5 -0
  37. package/src/tools/header/index.ts +47 -1
  38. package/src/tools/list/dom-builder.ts +3 -1
  39. package/src/tools/list/index.ts +55 -3
  40. package/src/tools/list/list-helpers.ts +2 -2
  41. package/src/tools/nested-blocks.ts +25 -0
  42. package/src/tools/paragraph/index.ts +47 -6
  43. package/src/tools/quote/index.ts +43 -8
  44. package/src/tools/stub/index.ts +10 -0
  45. package/src/tools/table/index.ts +238 -6
  46. package/src/tools/table/table-add-controls.ts +37 -5
  47. package/src/tools/table/table-cell-blocks.ts +57 -18
  48. package/src/tools/table/table-core.ts +2 -0
  49. package/src/tools/table/table-corner-drag.ts +247 -0
  50. package/src/tools/table/table-operations.ts +41 -14
  51. package/src/tools/toggle/dom-builder.ts +1 -0
  52. package/src/tools/toggle/index.ts +25 -0
  53. package/src/tools/toggle/toggle-lifecycle.ts +5 -4
  54. package/src/types-internal/jsdom.d.ts +9 -0
  55. package/types/tools/adapters/block-tool-adapter.d.ts +6 -0
  56. package/types/tools/block-tool.d.ts +10 -0
  57. package/bin/blok.mjs +0 -10
  58. package/dist/cli.mjs +0 -37
  59. package/src/tools/code/language-picker.ts +0 -241
@@ -1,7 +1,7 @@
1
1
  import type { I18n } from '../../../types/api';
2
2
  import { IconPlus } from '../../components/icons';
3
3
  import { createTooltipContent } from '../../components/modules/toolbar/tooltip';
4
- import { hide as hideTooltip, onHover } from '../../components/utils/tooltip';
4
+ import { hide as hideTooltip, onHover, show as showTooltip } from '../../components/utils/tooltip';
5
5
  import { twMerge } from '../../components/utils/tw';
6
6
 
7
7
  const ADD_ROW_ATTR = 'data-blok-table-add-row';
@@ -29,7 +29,7 @@ const VISUAL_CLASSES = [
29
29
  'justify-center',
30
30
  'border',
31
31
  'border-gray-300',
32
- 'rounded-full',
32
+ 'rounded-sm',
33
33
  'group-hover/add:bg-gray-50',
34
34
  ];
35
35
 
@@ -56,6 +56,7 @@ interface TableAddControlsOptions {
56
56
  onDragAddCol: () => void;
57
57
  onDragRemoveCol: () => void;
58
58
  onDragEnd: () => void;
59
+ getTableSize: () => { rows: number; cols: number };
59
60
  /** Returns the pixel width of a newly added column, used as the drag unit size. */
60
61
  getNewColumnWidth?: () => number;
61
62
  }
@@ -94,6 +95,7 @@ export class TableAddControls {
94
95
  private boundPointerCancel: (e: PointerEvent) => void;
95
96
  private boundRowPointerDown: (e: PointerEvent) => void;
96
97
  private boundColPointerDown: (e: PointerEvent) => void;
98
+ private getTableSize: () => { rows: number; cols: number };
97
99
  private getNewColumnWidth: (() => number) | undefined;
98
100
  private scrollContainer: HTMLElement | null = null;
99
101
  private boundScrollHandler: (() => void) | null = null;
@@ -112,6 +114,7 @@ export class TableAddControls {
112
114
  this.onDragAddCol = options.onDragAddCol;
113
115
  this.onDragRemoveCol = options.onDragRemoveCol;
114
116
  this.onDragEnd = options.onDragEnd;
117
+ this.getTableSize = options.getTableSize;
115
118
  this.getNewColumnWidth = options.getNewColumnWidth;
116
119
  this.boundMouseMove = this.handleMouseMove.bind(this);
117
120
  this.boundDocumentMouseMove = this.handleDocumentMouseMove.bind(this);
@@ -324,6 +327,20 @@ export class TableAddControls {
324
327
  this.addColBtn.remove();
325
328
  }
326
329
 
330
+ private showDimensionTooltip(): void {
331
+ if (!this.dragState) {
332
+ return;
333
+ }
334
+
335
+ const size = this.getTableSize();
336
+ const target = this.dragState.axis === 'row' ? this.addRowBtn : this.addColBtn;
337
+ const opts = this.dragState.axis === 'row'
338
+ ? { placement: 'bottom' as const, marginTop: -16 }
339
+ : { placement: 'bottom' as const };
340
+
341
+ showTooltip(target, `${size.cols}\u00D7${size.rows}`, opts);
342
+ }
343
+
327
344
  private handlePointerDown(axis: 'row' | 'col', e: PointerEvent): void {
328
345
  e.preventDefault();
329
346
 
@@ -380,8 +397,18 @@ export class TableAddControls {
380
397
  if (Math.abs(delta) > DRAG_THRESHOLD && !this.dragState.didDrag) {
381
398
  this.dragState.didDrag = true;
382
399
  document.body.style.cursor = axis === 'row' ? 'row-resize' : 'col-resize';
383
- hideTooltip();
400
+ this.showDimensionTooltip();
384
401
  this.onDragStart();
402
+
403
+ return;
404
+ }
405
+
406
+ if (this.dragState.didDrag) {
407
+ this.showDimensionTooltip();
408
+ }
409
+
410
+ if (this.dragState.didDrag) {
411
+ this.showDimensionTooltip();
385
412
  }
386
413
  }
387
414
 
@@ -400,6 +427,7 @@ export class TableAddControls {
400
427
  target.removeEventListener('pointercancel', this.boundPointerCancel);
401
428
 
402
429
  document.body.style.cursor = '';
430
+ hideTooltip();
403
431
  this.dragState = null;
404
432
 
405
433
  if (!didDrag) {
@@ -431,6 +459,7 @@ export class TableAddControls {
431
459
  target.removeEventListener('pointercancel', this.boundPointerCancel);
432
460
 
433
461
  document.body.style.cursor = '';
462
+ hideTooltip();
434
463
  this.dragState = null;
435
464
 
436
465
  if (didDrag) {
@@ -495,8 +524,8 @@ export class TableAddControls {
495
524
  * Document-level mousemove handler.
496
525
  * Catches mouse movements outside the wrapper (e.g. in the ::after
497
526
  * pseudo-element zone below the grid, which has pointer-events-none).
498
- * Only delegates to handleMouseMove when the cursor is within the
499
- * proximity zone around the grid to avoid unnecessary work.
527
+ * Delegates to handleMouseMove when the cursor is within the proximity
528
+ * zone around the grid; schedules hiding when the cursor is far away.
500
529
  */
501
530
  private handleDocumentMouseMove(e: MouseEvent): void {
502
531
  if (this.wrapper.contains(e.target as Node)) {
@@ -513,6 +542,9 @@ export class TableAddControls {
513
542
 
514
543
  if (nearGrid) {
515
544
  this.handleMouseMove(e);
545
+ } else {
546
+ this.scheduleHideRow();
547
+ this.scheduleHideCol();
516
548
  }
517
549
  }
518
550
 
@@ -1,4 +1,5 @@
1
1
  import type { API } from '../../../types';
2
+ import { DATA_ATTR } from '../../components/constants/data-attributes';
2
3
 
3
4
  import { CELL_ATTR, ROW_ATTR, CELL_COL_ATTR } from './table-core';
4
5
  import type { TableModel } from './table-model';
@@ -7,15 +8,6 @@ import { isCellWithBlocks } from './types';
7
8
 
8
9
  export const CELL_BLOCKS_ATTR = 'data-blok-table-cell-blocks';
9
10
 
10
- /**
11
- * Check if an element is inside a block-based table cell
12
- */
13
- export const isInCellBlock = (element: HTMLElement): boolean => {
14
- const cellBlocksContainer = element.closest(`[${CELL_BLOCKS_ATTR}]`);
15
-
16
- return cellBlocksContainer !== null;
17
- };
18
-
19
11
  /**
20
12
  * Get the cell element that contains the given element
21
13
  */
@@ -390,9 +382,9 @@ export class TableCellBlocks {
390
382
  const referencedBlockIds = isCellWithBlocks(cellContent) && cellContent.blocks.length > 0
391
383
  ? [...cellContent.blocks]
392
384
  : null;
393
- const mountedIds = referencedBlockIds
385
+ const { mountedIds, replacements } = referencedBlockIds
394
386
  ? this.mountBlocksInCell(container, referencedBlockIds)
395
- : [];
387
+ : { mountedIds: [] as string[], replacements: new Map<string, string>() };
396
388
 
397
389
  const cellColorProps: Pick<CellContent, 'color' | 'textColor'> = {};
398
390
 
@@ -406,7 +398,12 @@ export class TableCellBlocks {
406
398
  }
407
399
 
408
400
  if (mountedIds.length > 0) {
409
- normalizedRow.push({ blocks: referencedBlockIds ?? mountedIds, ...cellColorProps });
401
+ const baseIds = referencedBlockIds ?? mountedIds;
402
+ const blockIds = replacements.size > 0
403
+ ? baseIds.map(id => replacements.get(id) ?? id)
404
+ : baseIds;
405
+
406
+ normalizedRow.push({ blocks: blockIds, ...cellColorProps });
410
407
  } else {
411
408
  const text = typeof cellContent === 'string'
412
409
  ? cellContent
@@ -455,10 +452,15 @@ export class TableCellBlocks {
455
452
 
456
453
  /**
457
454
  * Mount existing blocks into a cell container by their IDs.
458
- * Returns the IDs of blocks that were successfully mounted.
455
+ * Returns the IDs of blocks that were successfully mounted and a map of
456
+ * original→duplicate IDs for blocks that were already in another cell.
459
457
  */
460
- private mountBlocksInCell(container: HTMLElement, blockIds: string[]): string[] {
458
+ private mountBlocksInCell(
459
+ container: HTMLElement,
460
+ blockIds: string[]
461
+ ): { mountedIds: string[]; replacements: Map<string, string> } {
461
462
  const mountedIds: string[] = [];
463
+ const replacements = new Map<string, string>();
462
464
 
463
465
  for (const blockId of blockIds) {
464
466
  const index = this.api.blocks.getBlockIndex(blockId);
@@ -473,11 +475,30 @@ export class TableCellBlocks {
473
475
  continue;
474
476
  }
475
477
 
478
+ // Guard: if the block is already mounted in another nested container
479
+ // (table cell, toggle, callout, header), create a duplicate with the
480
+ // same tool name and data rather than stealing the DOM node.
481
+ if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
482
+ const duplicate = this.api.blocks.insert(
483
+ block.name,
484
+ block.preservedData,
485
+ {},
486
+ this.api.blocks.getBlocksCount(),
487
+ false
488
+ );
489
+
490
+ container.appendChild(duplicate.holder);
491
+ this.api.blocks.setBlockParent(duplicate.id, this.tableBlockId);
492
+ mountedIds.push(duplicate.id);
493
+ replacements.set(blockId, duplicate.id);
494
+ continue;
495
+ }
496
+
476
497
  container.appendChild(block.holder);
477
498
  this.api.blocks.setBlockParent(blockId, this.tableBlockId);
478
499
  mountedIds.push(blockId);
479
500
  }
480
- return mountedIds;
501
+ return { mountedIds, replacements };
481
502
  }
482
503
 
483
504
  /**
@@ -508,6 +529,12 @@ export class TableCellBlocks {
508
529
  return;
509
530
  }
510
531
 
532
+ // Guard: skip blocks already mounted in another nested container.
533
+ // Without this, insertBefore would steal the DOM node from the other container.
534
+ if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
535
+ return;
536
+ }
537
+
511
538
  // Insert at the correct DOM position based on the flat array order,
512
539
  // so that pressing Enter on a non-last paragraph inserts the new block
513
540
  // right after the current one instead of always at the end of the cell.
@@ -1026,7 +1053,11 @@ export class TableCellBlocks {
1026
1053
  }
1027
1054
 
1028
1055
  /**
1029
- * Delete blocks by their IDs (in reverse index order to avoid shifting issues)
1056
+ * Delete blocks by their IDs (in reverse index order to avoid shifting issues).
1057
+ * Preserves scroll position because api.blocks.delete() is async — its internal
1058
+ * `await` defers Caret.setToBlock() to microtasks that run AFTER this method returns,
1059
+ * causing unwanted page jumps via element.focus() and window.scrollBy().
1060
+ * We use Promise.all().then() to schedule the scroll restore after all those microtasks.
1030
1061
  */
1031
1062
  public deleteBlocks(blockIds: string[]): void {
1032
1063
  const blockIndices = blockIds
@@ -1034,8 +1065,16 @@ export class TableCellBlocks {
1034
1065
  .filter((index): index is number => index !== undefined)
1035
1066
  .sort((a, b) => b - a);
1036
1067
 
1037
- blockIndices.forEach(index => {
1038
- void this.api.blocks.delete(index);
1068
+ const savedScrollY = window.scrollY;
1069
+
1070
+ const deletePromises = blockIndices.map(index => {
1071
+ return this.api.blocks.delete(index);
1072
+ });
1073
+
1074
+ void Promise.all(deletePromises).then(() => {
1075
+ if (window.scrollY !== savedScrollY) {
1076
+ window.scrollTo(0, savedScrollY);
1077
+ }
1039
1078
  });
1040
1079
  }
1041
1080
 
@@ -1,4 +1,5 @@
1
1
  import { twMerge } from '../../components/utils/tw';
2
+ import { DATA_ATTR } from '../../components/constants/data-attributes';
2
3
 
3
4
  import { CELL_BLOCKS_ATTR } from './table-cell-blocks';
4
5
  import type { TableModel } from './table-model';
@@ -631,6 +632,7 @@ export class TableGrid {
631
632
  const blocksContainer = document.createElement('div');
632
633
 
633
634
  blocksContainer.setAttribute(CELL_BLOCKS_ATTR, '');
635
+ blocksContainer.setAttribute(DATA_ATTR.nestedBlocks, '');
634
636
  blocksContainer.style.display = 'flex';
635
637
  blocksContainer.style.flexDirection = 'column';
636
638
  blocksContainer.style.minHeight = '100%';
@@ -0,0 +1,247 @@
1
+ import { show as showTooltip, hide as hideTooltip } from '../../components/utils/tooltip';
2
+
3
+ const CORNER_DRAG_ATTR = 'data-blok-table-corner-drag';
4
+
5
+ export interface TableCornerDragOptions {
6
+ wrapper: HTMLElement;
7
+ gridEl: HTMLElement;
8
+ onAddRow: () => void;
9
+ onAddColumn: () => void;
10
+ onRemoveLastRow: () => void;
11
+ onRemoveLastColumn: () => void;
12
+ onDragStart: () => void;
13
+ onDragEnd: () => void;
14
+ getTableSize: () => { rows: number; cols: number };
15
+ canRemoveLastRow: () => boolean;
16
+ canRemoveLastColumn: () => boolean;
17
+ onClickAdd?: () => void;
18
+ }
19
+
20
+ const DRAG_THRESHOLD = 5;
21
+
22
+ interface DragState {
23
+ startX: number;
24
+ startY: number;
25
+ unitWidth: number;
26
+ unitHeight: number;
27
+ addedRows: number;
28
+ addedCols: number;
29
+ pointerId: number;
30
+ didDrag: boolean;
31
+ }
32
+
33
+ export class TableCornerDrag {
34
+ private wrapper: HTMLElement;
35
+ private gridEl: HTMLElement;
36
+ private hitZone: HTMLElement;
37
+ private getTableSize: () => { rows: number; cols: number };
38
+ private onAddRow: () => void;
39
+ private onAddColumn: () => void;
40
+ private onRemoveLastRow: () => void;
41
+ private onRemoveLastColumn: () => void;
42
+ private onDragStart: () => void;
43
+ private onDragEnd: () => void;
44
+ private canRemoveLastRow: () => boolean;
45
+ private canRemoveLastColumn: () => boolean;
46
+ private onClickAdd: (() => void) | null;
47
+ private dragState: DragState | null = null;
48
+ private readonly boundMouseEnter: () => void;
49
+ private readonly boundMouseLeave: () => void;
50
+ private readonly boundPointerDown: (e: PointerEvent) => void;
51
+ private readonly boundPointerMove: (e: PointerEvent) => void;
52
+ private readonly boundPointerUp: (e: PointerEvent) => void;
53
+
54
+ constructor(options: TableCornerDragOptions) {
55
+ this.wrapper = options.wrapper;
56
+ this.gridEl = options.gridEl;
57
+ this.getTableSize = options.getTableSize;
58
+ this.onAddRow = options.onAddRow;
59
+ this.onAddColumn = options.onAddColumn;
60
+ this.onRemoveLastRow = options.onRemoveLastRow;
61
+ this.onRemoveLastColumn = options.onRemoveLastColumn;
62
+ this.onDragStart = options.onDragStart;
63
+ this.onDragEnd = options.onDragEnd;
64
+ this.canRemoveLastRow = options.canRemoveLastRow;
65
+ this.canRemoveLastColumn = options.canRemoveLastColumn;
66
+ this.onClickAdd = options.onClickAdd ?? null;
67
+
68
+ this.hitZone = document.createElement('div');
69
+ this.hitZone.setAttribute(CORNER_DRAG_ATTR, '');
70
+ this.hitZone.setAttribute('contenteditable', 'false');
71
+ this.hitZone.style.position = 'absolute';
72
+ this.hitZone.style.width = '36px';
73
+ this.hitZone.style.height = '36px';
74
+ this.hitZone.style.cursor = 'nwse-resize';
75
+ this.hitZone.style.zIndex = '2';
76
+ this.hitZone.style.pointerEvents = 'auto';
77
+ this.hitZone.style.bottom = '-36px';
78
+ this.hitZone.style.right = '-16px';
79
+
80
+ this.boundMouseEnter = this.handleMouseEnter.bind(this);
81
+ this.boundMouseLeave = this.handleMouseLeave.bind(this);
82
+ this.boundPointerDown = this.handlePointerDown.bind(this);
83
+ this.boundPointerMove = this.handlePointerMove.bind(this);
84
+ this.boundPointerUp = this.handlePointerUp.bind(this);
85
+
86
+ this.hitZone.addEventListener('mouseenter', this.boundMouseEnter);
87
+ this.hitZone.addEventListener('mouseleave', this.boundMouseLeave);
88
+ this.hitZone.addEventListener('pointerdown', this.boundPointerDown);
89
+
90
+ this.wrapper.appendChild(this.hitZone);
91
+ }
92
+
93
+ private updateTooltip(): void {
94
+ const size = this.getTableSize();
95
+
96
+ showTooltip(this.hitZone, `${size.cols}\u00D7${size.rows}`, { placement: 'bottom' });
97
+ }
98
+
99
+ private handleMouseEnter(): void {
100
+ this.updateTooltip();
101
+ }
102
+
103
+ private handleMouseLeave(): void {
104
+ if (this.dragState !== null) {
105
+ return;
106
+ }
107
+ hideTooltip();
108
+ }
109
+
110
+ private measureUnitHeight(): number {
111
+ const rows = this.gridEl.querySelectorAll('[data-blok-table-row]');
112
+ const lastRow = rows[rows.length - 1] as HTMLElement | undefined;
113
+
114
+ return lastRow?.offsetHeight || 30;
115
+ }
116
+
117
+ private measureUnitWidth(): number {
118
+ const firstRow = this.gridEl.querySelector('[data-blok-table-row]');
119
+
120
+ if (!firstRow) {
121
+ return 100;
122
+ }
123
+
124
+ const cells = firstRow.querySelectorAll('[data-blok-table-cell]');
125
+ const lastCell = cells[cells.length - 1] as HTMLElement | undefined;
126
+
127
+ return lastCell?.offsetWidth || 100;
128
+ }
129
+
130
+ private handlePointerDown(e: PointerEvent): void {
131
+ this.dragState = {
132
+ startX: e.clientX,
133
+ startY: e.clientY,
134
+ unitWidth: this.measureUnitWidth(),
135
+ unitHeight: this.measureUnitHeight(),
136
+ addedRows: 0,
137
+ addedCols: 0,
138
+ pointerId: e.pointerId,
139
+ didDrag: false,
140
+ };
141
+
142
+ this.updateTooltip();
143
+
144
+ this.hitZone.setPointerCapture(e.pointerId);
145
+ this.hitZone.addEventListener('pointermove', this.boundPointerMove);
146
+ this.hitZone.addEventListener('pointerup', this.boundPointerUp);
147
+ }
148
+
149
+ private handlePointerMove(e: PointerEvent): void {
150
+ if (this.dragState === null) {
151
+ return;
152
+ }
153
+
154
+ const dx = e.clientX - this.dragState.startX;
155
+ const dy = e.clientY - this.dragState.startY;
156
+
157
+ if (!this.dragState.didDrag) {
158
+ const distance = Math.sqrt(dx * dx + dy * dy);
159
+
160
+ if (distance < DRAG_THRESHOLD) {
161
+ return;
162
+ }
163
+
164
+ this.dragState.didDrag = true;
165
+ document.body.style.cursor = 'nwse-resize';
166
+ document.body.style.userSelect = 'none';
167
+ this.onDragStart();
168
+ }
169
+
170
+ const { unitHeight, unitWidth } = this.dragState;
171
+
172
+ const targetRows = Math.trunc(dy / unitHeight);
173
+ const targetCols = Math.trunc(dx / unitWidth);
174
+
175
+ while (this.dragState.addedRows < targetRows) {
176
+ this.onAddRow();
177
+ this.dragState.addedRows++;
178
+ }
179
+
180
+ while (this.dragState.addedRows > targetRows && this.canRemoveLastRow()) {
181
+ this.onRemoveLastRow();
182
+ this.dragState.addedRows--;
183
+ }
184
+
185
+ while (this.dragState.addedCols < targetCols) {
186
+ this.onAddColumn();
187
+ this.dragState.addedCols++;
188
+ }
189
+
190
+ while (this.dragState.addedCols > targetCols && this.canRemoveLastColumn()) {
191
+ this.onRemoveLastColumn();
192
+ this.dragState.addedCols--;
193
+ }
194
+
195
+ this.updateTooltip();
196
+ }
197
+
198
+ private handlePointerUp(_e: PointerEvent): void {
199
+ if (this.dragState === null) {
200
+ return;
201
+ }
202
+
203
+ const { didDrag, pointerId } = this.dragState;
204
+
205
+ this.dragState = null;
206
+ hideTooltip();
207
+ this.hitZone.releasePointerCapture(pointerId);
208
+ this.hitZone.removeEventListener('pointermove', this.boundPointerMove);
209
+ this.hitZone.removeEventListener('pointerup', this.boundPointerUp);
210
+
211
+ if (!didDrag) {
212
+ if (this.onClickAdd) {
213
+ this.onClickAdd();
214
+ } else {
215
+ this.onAddRow();
216
+ this.onAddColumn();
217
+ }
218
+ } else {
219
+ document.body.style.cursor = '';
220
+ document.body.style.userSelect = '';
221
+ this.onDragEnd();
222
+ }
223
+ }
224
+
225
+ public setDisplay(visible: boolean): void {
226
+ this.hitZone.style.display = visible ? '' : 'none';
227
+ }
228
+
229
+ public setInteractive(interactive: boolean): void {
230
+ this.hitZone.style.pointerEvents = interactive ? 'auto' : 'none';
231
+ }
232
+
233
+ public destroy(): void {
234
+ this.hitZone.removeEventListener('mouseenter', this.boundMouseEnter);
235
+ this.hitZone.removeEventListener('mouseleave', this.boundMouseLeave);
236
+ this.hitZone.removeEventListener('pointerdown', this.boundPointerDown);
237
+ this.hitZone.removeEventListener('pointermove', this.boundPointerMove);
238
+ this.hitZone.removeEventListener('pointerup', this.boundPointerUp);
239
+ if (this.dragState?.didDrag) {
240
+ document.body.style.cursor = '';
241
+ document.body.style.userSelect = '';
242
+ }
243
+ this.dragState = null;
244
+ hideTooltip();
245
+ this.hitZone.remove();
246
+ }
247
+ }
@@ -1,4 +1,5 @@
1
1
  import type { API } from '../../../types';
2
+ import { DATA_ATTR } from '../../components/constants/data-attributes';
2
3
 
3
4
  import type { TableCellBlocks } from './table-cell-blocks';
4
5
  import { CELL_BLOCKS_ATTR } from './table-cell-blocks';
@@ -288,16 +289,28 @@ export const mountCellBlocksReadOnly = (
288
289
 
289
290
  if (!isCellWithBlocks(cellContent)) {
290
291
  // Read-only render path must not mutate block state.
291
- // Use innerHTML so that legacy HTML markup (e.g. <b>bold</b>) is
292
- // interpreted by the browser rather than shown as literal text.
293
- container.innerHTML = cellContent;
292
+ // Wrap in a div with leading-[1.5] so the line-height matches paragraph
293
+ // blocks used in edit mode (where legacy strings are converted to real
294
+ // paragraph blocks with that line-height). Without this wrapper, the
295
+ // text inherits the cell's leading-none, producing shorter cells.
296
+ const wrapper = document.createElement('div');
297
+
298
+ wrapper.className = 'leading-[1.5]';
299
+ wrapper.innerHTML = cellContent;
300
+ container.replaceChildren(wrapper);
294
301
 
295
302
  return;
296
303
  }
297
304
 
298
- // If this container previously rendered legacy text, clear it before mounting holders.
299
- if (!hasExistingBlocks && (container.textContent ?? '').length > 0) {
300
- container.textContent = '';
305
+ // Clear the container before (re-)mounting block holders.
306
+ // This covers two cases:
307
+ // 1. Legacy text was previously rendered and needs to be replaced with blocks.
308
+ // 2. Block holders are already mounted (e.g. from edit mode before a
309
+ // setReadOnly toggle) — without clearing, the clone-guard below would
310
+ // duplicate every holder because it detects them inside a
311
+ // [data-blok-nested-blocks] container and appends a cloneNode(true).
312
+ if (hasExistingBlocks || (container.textContent ?? '').length > 0) {
313
+ container.replaceChildren();
301
314
  }
302
315
 
303
316
  for (const blockId of cellContent.blocks) {
@@ -313,12 +326,20 @@ export const mountCellBlocksReadOnly = (
313
326
  continue;
314
327
  }
315
328
 
329
+ // Skip blocks that don't belong to this table.
330
+ // Corrupted data may contain cross-table references; mounting them
331
+ // would steal (or clone) DOM nodes from the other table.
332
+ if (block.parentId !== _tableBlockId) {
333
+ continue;
334
+ }
335
+
316
336
  // Guard: if the block holder is already inside another table cell's
317
- // blocks container, skip it. Without this check, appendChild would
318
- // move (steal) the DOM node from the first table, leaving its cell
319
- // empty. This can happen when corrupted data references the same
320
- // block in multiple tables.
321
- if (block.holder.closest(`[${CELL_BLOCKS_ATTR}]`)) {
337
+ // blocks container, clone its visual content instead of moving (stealing)
338
+ // the DOM node. This can happen when corrupted data references the same
339
+ // block in multiple tables. In read-only mode a deep clone is safe
340
+ // because the content is non-interactive.
341
+ if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
342
+ container.appendChild(block.holder.cloneNode(true));
322
343
  continue;
323
344
  }
324
345
 
@@ -374,8 +395,8 @@ export const normalizeTableData = (
374
395
  export const setupKeyboardNavigation = (
375
396
  gridEl: HTMLElement,
376
397
  cellBlocks: TableCellBlocks | null,
377
- ): void => {
378
- gridEl.addEventListener('keydown', (event: KeyboardEvent) => {
398
+ ): (() => void) => {
399
+ const handler = (event: KeyboardEvent): void => {
379
400
  const target = event.target as HTMLElement;
380
401
  const cell = target.closest<HTMLElement>(`[${CELL_ATTR}]`);
381
402
 
@@ -388,7 +409,13 @@ export const setupKeyboardNavigation = (
388
409
  if (position) {
389
410
  cellBlocks?.handleKeyDown(event, position);
390
411
  }
391
- });
412
+ };
413
+
414
+ gridEl.addEventListener('keydown', handler);
415
+
416
+ return () => {
417
+ gridEl.removeEventListener('keydown', handler);
418
+ };
392
419
  };
393
420
 
394
421
  export const SCROLL_OVERFLOW_CLASSES = ['overflow-x-auto', 'overflow-y-hidden'];
@@ -88,6 +88,7 @@ export const buildToggleItem = (context: ToggleDOMBuilderContext): ToggleBuildRe
88
88
  const childContainerElement = document.createElement('div');
89
89
  childContainerElement.className = TOGGLE_CHILDREN_STYLES;
90
90
  childContainerElement.setAttribute(TOGGLE_ATTR.toggleChildren, '');
91
+ childContainerElement.setAttribute(DATA_ATTR.nestedBlocks, '');
91
92
  // Block DOM mutations inside the children container from triggering the toggle tool's
92
93
  // didMutated → syncBlockDataToYjs path. Child block insertions/removals are tracked
93
94
  // via the block hierarchy (parentId / contentIds) and must not create spurious Yjs
@@ -229,6 +229,31 @@ export class ToggleItem implements BlockTool {
229
229
  this.setOpenState(false);
230
230
  }
231
231
 
232
+ public setReadOnly(state: boolean): void {
233
+ if (!this._element) {
234
+ return;
235
+ }
236
+
237
+ const wasReadOnly = this.readOnly;
238
+
239
+ this.readOnly = state;
240
+
241
+ // Toggle contentEditable on the content element
242
+ if (this._contentElement) {
243
+ this._contentElement.contentEditable = state ? 'false' : 'true';
244
+ }
245
+
246
+ // Manage block changed event subscription
247
+ if (state && !wasReadOnly) {
248
+ this.api.events.off('block changed', this.handleBlockChanged);
249
+ } else if (!state && wasReadOnly) {
250
+ this.api.events.on('block changed', this.handleBlockChanged);
251
+ }
252
+
253
+ // Update body placeholder visibility (hidden in read-only mode)
254
+ this.updateBodyPlaceholderVisibility();
255
+ }
256
+
232
257
  public removed(): void {
233
258
  this.api.events.off('block changed', this.handleBlockChanged);
234
259
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { API } from '../../../types';
8
8
 
9
+ import { mountChildBlocks } from '../nested-blocks';
9
10
  import { setupPlaceholder } from '../../components/utils/placeholder';
10
11
 
11
12
  import { TOGGLE_ATTR } from './constants';
@@ -94,11 +95,11 @@ export const updateChildrenVisibility = (
94
95
  arrowElement.focus();
95
96
  }
96
97
 
97
- for (const child of children) {
98
- if (childContainer && child.holder.parentElement !== childContainer) {
99
- childContainer.appendChild(child.holder);
100
- }
98
+ if (childContainer) {
99
+ mountChildBlocks(childContainer, children);
100
+ }
101
101
 
102
+ for (const child of children) {
102
103
  if (isOpen) {
103
104
  child.holder.classList.remove('hidden');
104
105
  } else {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Declaration for jsdom (used in CLI for HTML conversion)
3
+ */
4
+ declare module 'jsdom' {
5
+ export class JSDOM {
6
+ constructor(html: string);
7
+ window: typeof globalThis;
8
+ }
9
+ }