@jackuait/blok 0.10.0-beta.8 → 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-CRvF-xVm.mjs → blok-BfcBwAfE.mjs} +1211 -1159
  3. package/dist/chunks/{constants-BOZ5plBi.mjs → constants-QNVyXALL.mjs} +49 -48
  4. package/dist/chunks/{tools-CnqCfv2L.mjs → tools-DHtzbrxy.mjs} +1411 -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 +87 -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 +45 -9
  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
@@ -52,6 +52,8 @@ export class ListItem implements BlockTool {
52
52
  private depthValidator: ListDepthValidator;
53
53
  private markerCalculator: ListMarkerCalculator;
54
54
  private markerManager: OrderedMarkerManager | null;
55
+ private placeholderCleanup: (() => void) | null = null;
56
+ private boundHandleKeyDown: ((event: KeyboardEvent) => void) | null = null;
55
57
 
56
58
  private blockId?: string;
57
59
 
@@ -120,16 +122,24 @@ export class ListItem implements BlockTool {
120
122
  if (this.readOnly) {
121
123
  return;
122
124
  }
123
- setupPlaceholder(element, this.placeholder);
125
+ this.placeholderCleanup = setupPlaceholder(element, this.placeholder);
124
126
  }
125
127
 
126
128
  public render(): HTMLElement {
129
+ if (this._element) {
130
+ return this._element;
131
+ }
132
+
127
133
  const blockIndex = this.blockId
128
134
  ? this.api.blocks.getBlockIndex(this.blockId) ?? this.api.blocks.getCurrentBlockIndex()
129
135
  : this.api.blocks.getCurrentBlockIndex();
130
136
  const depth = this._data.depth ?? 0;
131
137
  const markerDepth = this.markerCalculator.getVisualDepth(blockIndex, depth);
132
138
 
139
+ if (!this.boundHandleKeyDown) {
140
+ this.boundHandleKeyDown = this.handleKeyDown.bind(this);
141
+ }
142
+
133
143
  this._element = renderListItem({
134
144
  data: this._data,
135
145
  readOnly: this.readOnly,
@@ -145,12 +155,54 @@ export class ListItem implements BlockTool {
145
155
  content.classList.toggle('opacity-60', checked);
146
156
  }
147
157
  },
148
- keydownHandler: this.readOnly ? undefined : this.handleKeyDown.bind(this),
158
+ keydownHandler: this.readOnly ? undefined : this.boundHandleKeyDown,
149
159
  });
150
160
 
151
161
  return this._element;
152
162
  }
153
163
 
164
+ public setReadOnly(state: boolean): void {
165
+ if (!this._element) {
166
+ return;
167
+ }
168
+
169
+ this.readOnly = state;
170
+
171
+ const content = this.getContentElement();
172
+
173
+ // Toggle contentEditable on content container
174
+ if (content) {
175
+ content.contentEditable = state ? 'false' : 'true';
176
+ }
177
+
178
+ // Toggle checkbox disabled state for checklists
179
+ const checkbox = this._element.querySelector<HTMLInputElement>('input[type="checkbox"]');
180
+
181
+ if (checkbox) {
182
+ checkbox.disabled = state;
183
+ }
184
+
185
+ // Toggle keydown handler and placeholder
186
+ if (state) {
187
+ if (this.boundHandleKeyDown) {
188
+ this._element.removeEventListener('keydown', this.boundHandleKeyDown);
189
+ }
190
+
191
+ if (this.placeholderCleanup) {
192
+ this.placeholderCleanup();
193
+ this.placeholderCleanup = null;
194
+ }
195
+ } else {
196
+ if (this.boundHandleKeyDown) {
197
+ this._element.addEventListener('keydown', this.boundHandleKeyDown);
198
+ }
199
+
200
+ if (content) {
201
+ this.placeholderCleanup = setupPlaceholder(content, this.placeholder);
202
+ }
203
+ }
204
+ }
205
+
154
206
  public rendered(): void {
155
207
  this.updateMarkersAfterPositionChange();
156
208
  }
@@ -408,7 +460,7 @@ export class ListItem implements BlockTool {
408
460
  content.classList.toggle('opacity-60', checked);
409
461
  }
410
462
  },
411
- keydownHandler: this.readOnly ? undefined : this.handleKeyDown.bind(this),
463
+ keydownHandler: this.readOnly ? undefined : this.boundHandleKeyDown ?? undefined,
412
464
  });
413
465
 
414
466
  if (newElement) {
@@ -19,8 +19,8 @@ export const getContentElement = (
19
19
  if (!element) return null;
20
20
 
21
21
  if (style === 'checklist') {
22
- const contentEditable = element.querySelector('[contenteditable]');
23
- return contentEditable instanceof HTMLElement ? contentEditable : null;
22
+ const checklistContent = element.querySelector('[data-blok-testid="list-checklist-content"]');
23
+ return checklistContent instanceof HTMLElement ? checklistContent : null;
24
24
  }
25
25
 
26
26
  const contentContainer = element.querySelector('[data-blok-testid="list-content-container"]');
@@ -0,0 +1,25 @@
1
+ import { DATA_ATTR } from '../components/constants/data-attributes';
2
+
3
+ /**
4
+ * Mount child block holders into a container, skipping children that are
5
+ * already in place or claimed by another nested-blocks container.
6
+ *
7
+ * Used by toggle, header, and callout tools to reconcile child holders
8
+ * during the `rendered()` lifecycle hook.
9
+ */
10
+ export const mountChildBlocks = (
11
+ container: HTMLElement,
12
+ children: { holder: HTMLElement }[],
13
+ ): void => {
14
+ for (const child of children) {
15
+ if (child.holder.parentElement === container) {
16
+ continue;
17
+ }
18
+
19
+ if (child.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
20
+ continue;
21
+ }
22
+
23
+ container.appendChild(child.holder);
24
+ }
25
+ };
@@ -19,7 +19,7 @@ import type {
19
19
  import { DATA_ATTR } from '../../components/constants';
20
20
  import { IconText } from '../../components/icons';
21
21
  import { stripFakeBackgroundElements } from '../../components/utils';
22
- import { PLACEHOLDER_EMPTY_EDITOR_CLASSES, PLACEHOLDER_FOCUS_ONLY_CLASSES, setupPlaceholder } from '../../components/utils/placeholder';
22
+ import { isContentEmpty, PLACEHOLDER_EMPTY_EDITOR_CLASSES, PLACEHOLDER_FOCUS_ONLY_CLASSES, setupPlaceholder } from '../../components/utils/placeholder';
23
23
  import { twMerge } from '../../components/utils/tw';
24
24
 
25
25
  /**
@@ -117,6 +117,11 @@ export class Paragraph implements BlockTool {
117
117
  */
118
118
  private readOnly: boolean;
119
119
 
120
+ /**
121
+ * Cleanup function for placeholder behavior, returned by setupPlaceholder
122
+ */
123
+ private placeholderCleanup: (() => void) | null = null;
124
+
120
125
  /**
121
126
  * Placeholder for Paragraph Tool
122
127
  */
@@ -157,9 +162,7 @@ export class Paragraph implements BlockTool {
157
162
  this.api = api;
158
163
  this.readOnly = readOnly;
159
164
 
160
- if (!this.readOnly) {
161
- this.onKeyUp = this.onKeyUp.bind(this);
162
- }
165
+ this.onKeyUp = this.onKeyUp.bind(this);
163
166
 
164
167
  this._placeholder = config?.placeholder ?? Paragraph.DEFAULT_PLACEHOLDER;
165
168
  this._data = data ?? { text: '' };
@@ -263,7 +266,7 @@ export class Paragraph implements BlockTool {
263
266
  if (!this.readOnly) {
264
267
  div.contentEditable = 'true';
265
268
  div.addEventListener('keyup', this.onKeyUp);
266
- setupPlaceholder(div, this.api.i18n.t(this._placeholder), 'data-blok-placeholder-active');
269
+ this.placeholderCleanup = setupPlaceholder(div, this.api.i18n.t(this._placeholder), 'data-blok-placeholder-active');
267
270
  }
268
271
 
269
272
  return div;
@@ -275,11 +278,49 @@ export class Paragraph implements BlockTool {
275
278
  * @returns HTMLDivElement
276
279
  */
277
280
  public render(): HTMLDivElement {
278
- this._element = this.drawView();
281
+ if (!this._element) {
282
+ this._element = this.drawView();
283
+ }
279
284
 
280
285
  return this._element;
281
286
  }
282
287
 
288
+ /**
289
+ * Toggle read-only mode in-place without re-rendering the DOM element.
290
+ * Manages contentEditable, keyup listener, placeholder, and empty-content <br>.
291
+ *
292
+ * @param state - true to enter read-only mode, false to exit
293
+ */
294
+ public setReadOnly(state: boolean): void {
295
+ if (!this._element) {
296
+ return;
297
+ }
298
+
299
+ this.readOnly = state;
300
+
301
+ if (state) {
302
+ this._element.contentEditable = 'false';
303
+ this._element.removeEventListener('keyup', this.onKeyUp);
304
+
305
+ if (this.placeholderCleanup) {
306
+ this.placeholderCleanup();
307
+ this.placeholderCleanup = null;
308
+ }
309
+
310
+ if (isContentEmpty(this._element)) {
311
+ this._element.innerHTML = '<br>';
312
+ }
313
+ } else {
314
+ this._element.contentEditable = 'true';
315
+ this._element.addEventListener('keyup', this.onKeyUp);
316
+ this.placeholderCleanup = setupPlaceholder(this._element, this.api.i18n.t(this._placeholder), 'data-blok-placeholder-active');
317
+
318
+ if (this._element.innerHTML === '<br>') {
319
+ this._element.innerHTML = '';
320
+ }
321
+ }
322
+ }
323
+
283
324
  /**
284
325
  * Method that specified how to merge two Text blocks.
285
326
  * Called by Editor by backspace at the beginning of the Block
@@ -13,7 +13,7 @@ import type { MenuConfig } from '../../../types/tools/menu-config';
13
13
  import { DATA_ATTR } from '../../components/constants';
14
14
  import { IconQuote } from '../../components/icons';
15
15
  import { stripFakeBackgroundElements } from '../../components/utils';
16
- import { PLACEHOLDER_FOCUS_ONLY_CLASSES, setupPlaceholder } from '../../components/utils/placeholder';
16
+ import { isContentEmpty, PLACEHOLDER_FOCUS_ONLY_CLASSES, setupPlaceholder } from '../../components/utils/placeholder';
17
17
  import { twMerge } from '../../components/utils/tw';
18
18
 
19
19
  export interface QuoteData extends BlockToolData {
@@ -40,6 +40,7 @@ const LARGE_CLASS = 'text-[1.2em]';
40
40
  export class Quote implements BlockTool {
41
41
  private api: API;
42
42
  private readOnly: boolean;
43
+ private placeholderCleanup: (() => void) | null = null;
43
44
  private _data: QuoteData;
44
45
  private _element: HTMLQuoteElement | null = null;
45
46
 
@@ -51,9 +52,7 @@ export class Quote implements BlockTool {
51
52
  size: data?.size ?? 'default',
52
53
  };
53
54
 
54
- if (!this.readOnly) {
55
- this.onKeyUp = this.onKeyUp.bind(this);
56
- }
55
+ this.onKeyUp = this.onKeyUp.bind(this);
57
56
  }
58
57
 
59
58
  public onKeyUp(e: KeyboardEvent): void {
@@ -70,7 +69,7 @@ export class Quote implements BlockTool {
70
69
  }
71
70
  }
72
71
 
73
- public render(): HTMLQuoteElement {
72
+ private drawView(): HTMLQuoteElement {
74
73
  const el = document.createElement('blockquote');
75
74
 
76
75
  el.className = twMerge(
@@ -91,14 +90,50 @@ export class Quote implements BlockTool {
91
90
  if (!this.readOnly) {
92
91
  el.contentEditable = 'true';
93
92
  el.addEventListener('keyup', this.onKeyUp);
94
- setupPlaceholder(el, this.api.i18n.t(DEFAULT_PLACEHOLDER), 'data-blok-placeholder-active');
93
+ this.placeholderCleanup = setupPlaceholder(el, this.api.i18n.t(DEFAULT_PLACEHOLDER), 'data-blok-placeholder-active');
95
94
  }
96
95
 
97
- this._element = el;
98
-
99
96
  return el;
100
97
  }
101
98
 
99
+ public render(): HTMLQuoteElement {
100
+ if (!this._element) {
101
+ this._element = this.drawView();
102
+ }
103
+
104
+ return this._element;
105
+ }
106
+
107
+ public setReadOnly(state: boolean): void {
108
+ if (!this._element) {
109
+ return;
110
+ }
111
+
112
+ this.readOnly = state;
113
+
114
+ if (state) {
115
+ this._element.contentEditable = 'false';
116
+ this._element.removeEventListener('keyup', this.onKeyUp);
117
+
118
+ if (this.placeholderCleanup) {
119
+ this.placeholderCleanup();
120
+ this.placeholderCleanup = null;
121
+ }
122
+
123
+ if (isContentEmpty(this._element)) {
124
+ this._element.innerHTML = '<br>';
125
+ }
126
+ } else {
127
+ this._element.contentEditable = 'true';
128
+ this._element.addEventListener('keyup', this.onKeyUp);
129
+ this.placeholderCleanup = setupPlaceholder(this._element, this.api.i18n.t(DEFAULT_PLACEHOLDER), 'data-blok-placeholder-active');
130
+
131
+ if (this._element.innerHTML === '<br>') {
132
+ this._element.innerHTML = '';
133
+ }
134
+ }
135
+ }
136
+
102
137
  public save(blockContent: HTMLQuoteElement): QuoteData {
103
138
  return {
104
139
  text: stripFakeBackgroundElements(blockContent.innerHTML),
@@ -73,6 +73,16 @@ export class Stub implements BlockTool {
73
73
  return this.savedData;
74
74
  }
75
75
 
76
+ /**
77
+ * Toggle read-only mode in-place.
78
+ * Stub has no interactive elements, so this is intentionally a no-op.
79
+ *
80
+ * @param _state - read-only state (unused)
81
+ */
82
+ public setReadOnly(_state: boolean): void {
83
+ // no-op: stub blocks have no editable content
84
+ }
85
+
76
86
  /**
77
87
  * Create Tool html markup
78
88
  * @returns {HTMLElement}
@@ -54,8 +54,10 @@ import type { PendingHighlight } from './table-row-col-action-handler';
54
54
  import { TableRowColControls } from './table-row-col-controls';
55
55
  import type { RowColAction } from './table-row-col-controls';
56
56
  import { registerAdditionalRestrictedTools } from './table-restrictions';
57
+ import { TableCornerDrag } from './table-corner-drag';
57
58
  import { TableScrollHaze } from './table-scroll-haze';
58
59
  import type { CellPlacement, ClipboardBlockData, LegacyCellContent, TableCellsClipboard, TableData, TableConfig } from './types';
60
+ import { isCellWithBlocks } from './types';
59
61
 
60
62
  const DEFAULT_ROWS = 3;
61
63
  const DEFAULT_COLS = 3;
@@ -93,6 +95,7 @@ export class Table implements BlockTool {
93
95
  private rowColControls: TableRowColControls | null = null;
94
96
  private cellBlocks: TableCellBlocks | null = null;
95
97
  private cellSelection: TableCellSelection | null = null;
98
+ private cornerDrag: TableCornerDrag | null = null;
96
99
  private scrollHaze: TableScrollHaze | null = null;
97
100
  private element: HTMLDivElement | null = null;
98
101
  private gridElement: HTMLElement | null = null;
@@ -102,6 +105,8 @@ export class Table implements BlockTool {
102
105
  private pendingHighlight: PendingHighlight | null = null;
103
106
  private isNewTable = false;
104
107
  private unregisterRestrictedTools: (() => void) | null = null;
108
+ private gridPasteCleanup: (() => void) | null = null;
109
+ private keyboardNavCleanup: (() => void) | null = null;
105
110
 
106
111
  /**
107
112
  * Generation counter for setData calls.
@@ -193,12 +198,18 @@ export class Table implements BlockTool {
193
198
  this.resize = null;
194
199
  this.addControls?.destroy();
195
200
  this.addControls = null;
201
+ this.cornerDrag?.destroy();
202
+ this.cornerDrag = null;
196
203
  this.rowColControls?.destroy();
197
204
  this.rowColControls = null;
198
205
  this.cellSelection?.destroy();
199
206
  this.cellSelection = null;
200
207
  this.scrollHaze?.destroy();
201
208
  this.scrollHaze = null;
209
+ this.gridPasteCleanup?.();
210
+ this.gridPasteCleanup = null;
211
+ this.keyboardNavCleanup?.();
212
+ this.keyboardNavCleanup = null;
202
213
  }
203
214
 
204
215
  /**
@@ -256,6 +267,8 @@ export class Table implements BlockTool {
256
267
  newTbody: Element,
257
268
  blockHolders: Map<string, HTMLElement>
258
269
  ): void {
270
+ const mounted = new Set<string>();
271
+
259
272
  content.forEach((rowData, r) => {
260
273
  rowData.forEach((cellContent, c) => {
261
274
  if (typeof cellContent === 'string') {
@@ -283,8 +296,9 @@ export class Table implements BlockTool {
283
296
  cellContent.blocks.forEach(blockId => {
284
297
  const holder = blockHolders.get(blockId);
285
298
 
286
- if (holder) {
299
+ if (holder && !mounted.has(blockId)) {
287
300
  container.appendChild(holder);
301
+ mounted.add(blockId);
288
302
  }
289
303
  });
290
304
  });
@@ -323,6 +337,7 @@ export class Table implements BlockTool {
323
337
  private initSubsystems(gridEl: HTMLElement): void {
324
338
  this.initResize(gridEl);
325
339
  this.initAddControls(gridEl);
340
+ this.initCornerDrag(gridEl);
326
341
  this.initRowColControls(gridEl);
327
342
  this.initCellSelection(gridEl);
328
343
  this.initGridPasteListener(gridEl);
@@ -482,7 +497,7 @@ export class Table implements BlockTool {
482
497
 
483
498
  if (!this.readOnly) {
484
499
  this.initCellBlocks(gridEl);
485
- setupKeyboardNavigation(gridEl, this.cellBlocks);
500
+ this.keyboardNavCleanup = setupKeyboardNavigation(gridEl, this.cellBlocks);
486
501
  }
487
502
 
488
503
  return wrapper;
@@ -558,6 +573,64 @@ export class Table implements BlockTool {
558
573
  }
559
574
  }
560
575
 
576
+ /**
577
+ * Toggle read-only mode in place without re-rendering.
578
+ * Entering readonly tears down all interactive subsystems and cell blocks;
579
+ * exiting readonly recreates them.
580
+ */
581
+ public setReadOnly(state: boolean): void {
582
+ const wrapper = this.element;
583
+ const gridEl = this.gridElement;
584
+
585
+ if (!wrapper || !gridEl) {
586
+ return;
587
+ }
588
+
589
+ this.readOnly = state;
590
+
591
+ if (state) {
592
+ // Entering readonly: tear down interactive subsystems
593
+ this.teardownSubsystems();
594
+ this.cellBlocks?.destroy();
595
+ this.cellBlocks = null;
596
+
597
+ // Remove grip overlay
598
+ if (this.gripOverlay) {
599
+ this.gripOverlay.remove();
600
+ this.gripOverlay = null;
601
+ }
602
+
603
+ // Update wrapper classes and attributes
604
+ WRAPPER_EDIT_CLASSES.forEach(cls => wrapper.classList.remove(cls));
605
+ wrapper.setAttribute('data-blok-table-readonly', '');
606
+
607
+ // Mount cell content as non-interactive
608
+ const snap = this.model.snapshot();
609
+
610
+ mountCellBlocksReadOnly(gridEl, snap.content, this.api, this.blockId ?? '');
611
+ } else {
612
+ // Exiting readonly: restore interactive subsystems
613
+ wrapper.removeAttribute('data-blok-table-readonly');
614
+ WRAPPER_EDIT_CLASSES.forEach(cls => wrapper.classList.add(cls));
615
+
616
+ // Create grip overlay
617
+ const overlay = document.createElement('div');
618
+
619
+ overlay.setAttribute('data-blok-table-grip-overlay', '');
620
+ overlay.style.position = 'absolute';
621
+ overlay.style.inset = '0';
622
+ overlay.style.pointerEvents = 'none';
623
+ overlay.style.zIndex = '3';
624
+ wrapper.appendChild(overlay);
625
+ this.gripOverlay = overlay;
626
+
627
+ // Initialize cell blocks and subsystems
628
+ this.initCellBlocks(gridEl);
629
+ this.keyboardNavCleanup = setupKeyboardNavigation(gridEl, this.cellBlocks);
630
+ this.initSubsystems(gridEl);
631
+ }
632
+ }
633
+
561
634
  /**
562
635
  * Remove blocks that claim this table as parent but are not referenced in any cell.
563
636
  *
@@ -595,7 +668,28 @@ export class Table implements BlockTool {
595
668
  }
596
669
 
597
670
  public save(_blockContent: HTMLElement): TableData {
598
- return this.model.snapshot();
671
+ const data = this.model.snapshot();
672
+
673
+ // Filter out block IDs that don't belong to this table.
674
+ // Corrupted data may contain cross-table references; persisting them
675
+ // causes DOM node stealing and data loss on subsequent renders.
676
+ data.content = data.content.map(row =>
677
+ row.map(cell => {
678
+ if (!isCellWithBlocks(cell)) {
679
+ return cell;
680
+ }
681
+
682
+ const filtered = cell.blocks.filter(blockId => {
683
+ const block = this.api.blocks.getById?.(blockId);
684
+
685
+ return !block || block.parentId === this.blockId;
686
+ });
687
+
688
+ return { ...cell, blocks: filtered };
689
+ })
690
+ );
691
+
692
+ return data;
599
693
  }
600
694
 
601
695
  public validate(savedData: TableData): boolean {
@@ -900,6 +994,10 @@ export class Table implements BlockTool {
900
994
  wrapper: this.element,
901
995
  grid: gridEl,
902
996
  i18n: this.api.i18n,
997
+ getTableSize: () => ({
998
+ rows: this.model.rows,
999
+ cols: this.model.cols,
1000
+ }),
903
1001
  getNewColumnWidth: () => {
904
1002
  const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
905
1003
 
@@ -1040,6 +1138,131 @@ export class Table implements BlockTool {
1040
1138
  }
1041
1139
  }
1042
1140
 
1141
+ private initCornerDrag(gridEl: HTMLElement): void {
1142
+ this.cornerDrag?.destroy();
1143
+
1144
+ if (!this.element) {
1145
+ return;
1146
+ }
1147
+
1148
+ this.cornerDrag = new TableCornerDrag({
1149
+ wrapper: this.element,
1150
+ gridEl,
1151
+ onAddRow: () => {
1152
+ this.runStructuralOp(() => {
1153
+ this.grid.addRow(gridEl);
1154
+ this.model.addRow();
1155
+ populateNewCells(gridEl, this.cellBlocks);
1156
+ updateHeadingStyles(this.gridElement, this.model.withHeadings);
1157
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1158
+ });
1159
+ },
1160
+ onAddColumn: () => {
1161
+ this.runStructuralOp(() => {
1162
+ const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
1163
+ const halfWidth = this.model.initialColWidth !== undefined
1164
+ ? Math.round((this.model.initialColWidth / 2) * 100) / 100
1165
+ : computeHalfAvgWidth(colWidths);
1166
+ const newWidths = [...colWidths, halfWidth];
1167
+
1168
+ this.grid.addColumn(gridEl, undefined, colWidths, halfWidth);
1169
+ this.model.addColumn(undefined, halfWidth);
1170
+ this.model.setColWidths(newWidths);
1171
+ applyPixelWidths(gridEl, newWidths);
1172
+ enableScrollOverflow(this.ensureScrollContainer());
1173
+ populateNewCells(gridEl, this.cellBlocks);
1174
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1175
+ });
1176
+ },
1177
+ onRemoveLastRow: () => {
1178
+ this.runStructuralOp(() => {
1179
+ const rowCount = this.grid.getRowCount(gridEl);
1180
+
1181
+ if (rowCount <= 1) {
1182
+ return;
1183
+ }
1184
+
1185
+ const { blocksToDelete } = this.model.deleteRow(rowCount - 1);
1186
+
1187
+ this.cellBlocks?.deleteBlocks(blocksToDelete);
1188
+ this.grid.deleteRow(gridEl, rowCount - 1);
1189
+ });
1190
+ },
1191
+ onRemoveLastColumn: () => {
1192
+ this.runStructuralOp(() => {
1193
+ const colCount = this.grid.getColumnCount(gridEl);
1194
+
1195
+ if (colCount <= 1) {
1196
+ return;
1197
+ }
1198
+
1199
+ const { blocksToDelete } = this.model.deleteColumn(colCount - 1);
1200
+
1201
+ this.cellBlocks?.deleteBlocks(blocksToDelete);
1202
+ this.grid.deleteColumn(gridEl, colCount - 1);
1203
+
1204
+ const updatedWidths = this.model.colWidths;
1205
+
1206
+ if (updatedWidths) {
1207
+ applyPixelWidths(gridEl, updatedWidths);
1208
+ }
1209
+ });
1210
+ },
1211
+ onDragStart: () => {
1212
+ if (this.resize) {
1213
+ this.resize.enabled = false;
1214
+ }
1215
+ this.rowColControls?.hideAllGrips();
1216
+ this.rowColControls?.setGripsDisplay(false);
1217
+ this.addControls?.setDisplay(false);
1218
+ },
1219
+ onDragEnd: () => {
1220
+ this.initResize(gridEl);
1221
+ this.rowColControls?.refresh();
1222
+ this.addControls?.setDisplay(true);
1223
+ this.addControls?.syncRowButtonWidth();
1224
+ },
1225
+ getTableSize: () => {
1226
+ return { rows: this.model.rows, cols: this.model.cols };
1227
+ },
1228
+ canRemoveLastRow: () => {
1229
+ return this.model.rows > 1 && isRowEmpty(gridEl, this.model.rows - 1);
1230
+ },
1231
+ canRemoveLastColumn: () => {
1232
+ return this.model.cols > 1 && isColumnEmpty(gridEl, this.model.cols - 1);
1233
+ },
1234
+ onClickAdd: () => {
1235
+ this.runTransactedStructuralOp(() => {
1236
+ // Add row
1237
+ this.grid.addRow(gridEl);
1238
+ this.model.addRow();
1239
+ populateNewCells(gridEl, this.cellBlocks);
1240
+ updateHeadingStyles(this.gridElement, this.model.withHeadings);
1241
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1242
+
1243
+ // Add column
1244
+ const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
1245
+ const halfWidth = this.model.initialColWidth !== undefined
1246
+ ? Math.round((this.model.initialColWidth / 2) * 100) / 100
1247
+ : computeHalfAvgWidth(colWidths);
1248
+ const newWidths = [...colWidths, halfWidth];
1249
+
1250
+ this.grid.addColumn(gridEl, undefined, colWidths, halfWidth);
1251
+ this.model.addColumn(undefined, halfWidth);
1252
+ this.model.setColWidths(newWidths);
1253
+ applyPixelWidths(gridEl, newWidths);
1254
+ populateNewCells(gridEl, this.cellBlocks);
1255
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1256
+
1257
+ // Refresh subsystems
1258
+ this.initResize(gridEl);
1259
+ this.rowColControls?.refresh();
1260
+ this.addControls?.syncRowButtonWidth();
1261
+ });
1262
+ },
1263
+ });
1264
+ }
1265
+
1043
1266
  private initRowColControls(gridEl: HTMLElement): void {
1044
1267
  this.rowColControls?.destroy();
1045
1268
 
@@ -1063,6 +1286,7 @@ export class Table implements BlockTool {
1063
1286
  }
1064
1287
 
1065
1288
  this.addControls?.setDisplay(!isDragging);
1289
+ this.cornerDrag?.setDisplay(!isDragging);
1066
1290
 
1067
1291
  if (isDragging) {
1068
1292
  this.api.toolbar.close({ setExplicitlyClosed: false });
@@ -1442,6 +1666,7 @@ export class Table implements BlockTool {
1442
1666
  }
1443
1667
 
1444
1668
  this.addControls?.setInteractive(!hasSelection);
1669
+ this.cornerDrag?.setInteractive(!hasSelection);
1445
1670
  this.rowColControls?.setGripsDisplay(!hasSelection);
1446
1671
  },
1447
1672
  onSelectionRangeChange: () => {
@@ -1531,9 +1756,14 @@ export class Table implements BlockTool {
1531
1756
  }
1532
1757
 
1533
1758
  private initGridPasteListener(gridEl: HTMLElement): void {
1534
- gridEl.addEventListener('paste', (e: ClipboardEvent) => {
1759
+ const handler = (e: ClipboardEvent): void => {
1535
1760
  this.handleGridPaste(e, gridEl);
1536
- });
1761
+ };
1762
+
1763
+ gridEl.addEventListener('paste', handler);
1764
+ this.gridPasteCleanup = () => {
1765
+ gridEl.removeEventListener('paste', handler);
1766
+ };
1537
1767
  }
1538
1768
 
1539
1769
  private handleGridPaste(e: ClipboardEvent, gridEl: HTMLElement): void {
@@ -1633,7 +1863,9 @@ export class Table implements BlockTool {
1633
1863
 
1634
1864
  const range = selection.getRangeAt(0);
1635
1865
 
1636
- range.deleteContents();
1866
+ if (!range.collapsed) {
1867
+ range.deleteContents();
1868
+ }
1637
1869
 
1638
1870
  const fragment = document.createDocumentFragment();
1639
1871
  const wrapper = document.createElement('div');