PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.1.5
Tableberg – Simple Gutenberg Table Block v1.1.5
1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 trunk 0.0.2 0.2.1 0.3.2 0.3.3 0.4.1 0.5.0 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 All 42 releases
tableberg / src / store / index.tsx

index.tsx in Tableberg – Simple Gutenberg Table Block 1.1.5, at src/store/index.tsx

1,988 lines 67.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { createContext, useContext, ReactNode, useRef, useEffect } from "react";
2 import { create } from "zustand";
3 import {
4 attrDefaults,
5 TablebergBlockAttrs,
6 TableConfig,
7 TableCellStylesType,
8 CellElement,
9 SortableType,
10 ColumnConfig,
11 RowConfig,
12 PaginationConfig,
13 RibbonConfig,
14 BindingDefinition,
15 Cell,
16 CellKey,
17 getCellKey,
18 parseCellKey,
19 } from "../attributes";
20 import { ElementBindings } from "../dynamic-data/types";
21 import { SortOrder, sortRowsByColumn, isColumnSortable } from "../sorting";
22 import { tableHasRowSpanningCells, getTotalPages } from "../pagination";
23 import { filterRowsBySearch } from "../search";
24 import { createElement } from "../elements";
25 import {
26 insertRowAt,
27 insertColumnAt,
28 deleteRowAt,
29 deleteColumnAt,
30 duplicateRowAt,
31 duplicateColumnAt,
32 moveRowTo,
33 moveColumnTo,
34 hasMergedCells,
35 } from "../table-structure";
36 import { cloneValue } from "../hooks/block-editor-compat/elementClipboard";
37
38 export type TableEditPreview = {
39 operation: "insert" | "duplicate" | "delete";
40 target: "row" | "column";
41 index: number;
42 } | null;
43
44 export interface SelectedElement {
45 cell: CellKey;
46 elementIndex: number;
47 }
48
49 export interface TableState extends TablebergBlockAttrs {
50 selectedCells: Array<CellKey>;
51 /** Native editor's modifier-click marquee (cell block clientIds). */
52 nativeSelectedCells: string[];
53 selectedElement: SelectedElement | null;
54 selectedRibbonCell: CellKey | null;
55 tableEditPreview: TableEditPreview;
56
57 sortPreviewMode: boolean;
58 previewSortColumn: number | null;
59 previewSortOrder: SortOrder;
60
61 currentPage: number;
62
63 searchTerm: string;
64 showCaption: boolean;
65 showRowColumnControls: boolean;
66 showDuplicateMoveControls: boolean;
67
68 setTable: (table: TableConfig) => void;
69 setCells: (cells: Record<CellKey, Cell>) => void;
70 setCellDefaults: (
71 cellDefaults: TablebergBlockAttrs["cellDefaults"]
72 ) => void;
73 setBindings: (bindings: Record<string, BindingDefinition>) => void;
74 setSelectedCells: (coords: Array<CellKey>) => void;
75 setNativeSelectedCells: (clientIds: string[]) => void;
76 setSelectedElement: (cell: CellKey, elementIndex: number) => void;
77 clearSelectedElement: () => void;
78 isElementSelected: (cell: CellKey, elementIndex: number) => boolean;
79
80 updateTable: (updates: Partial<TableConfig>) => void;
81 updateCellGlobalStyles: (updates: Partial<TableCellStylesType>) => void;
82 updateCellStyles: (
83 coords: CellKey,
84 updates: Partial<TableCellStylesType>
85 ) => void;
86 addSelectedCells: (coords: Array<CellKey>) => void;
87
88 getCellStyle: (coord: CellKey) => Partial<TableCellStylesType> | undefined;
89 getCellSpan: (coord: CellKey) => { rowSpan: number; colSpan: number };
90 getCellRibbon: (coord: CellKey) => RibbonConfig | undefined;
91 setCellRibbon: (coord: CellKey, ribbon: RibbonConfig | undefined) => void;
92 setSelectedRibbon: (cell: CellKey) => void;
93 clearSelectedRibbon: () => void;
94 isRibbonSelected: (cell: CellKey) => boolean;
95 setTableEditPreview: (preview: TableEditPreview) => void;
96 clearTableEditPreview: () => void;
97
98 addElementToCell: (coord: CellKey, element: CellElement) => void;
99 insertElementInCell: (
100 coord: CellKey,
101 elementIndex: number,
102 element: CellElement
103 ) => void;
104 reorderElementsInCell: (
105 coord: CellKey,
106 sourceIndicesInNextOrder: number[]
107 ) => void;
108 duplicateElementInCell: (coord: CellKey, elementIndex: number) => void;
109 removeElementFromCell: (coord: CellKey, elementIndex: number) => void;
110 updateCellElement: (
111 coord: CellKey,
112 elementIndex: number,
113 updates: { attributes: Partial<CellElement["attributes"]> }
114 ) => void;
115 updateSelectedElementStyles: (styles: Record<string, unknown>) => void;
116 updateSelectedElementAttrs: (attrs: Record<string, unknown>) => void;
117 updateSelectedElementBindings: (
118 bindings: ElementBindings | undefined
119 ) => void;
120 createBindingDefinition: (binding: BindingDefinition) => string;
121 updateBindingDefinition: (
122 bindingId: string,
123 binding: BindingDefinition
124 ) => void;
125 removeBindingDefinition: (bindingId: string) => void;
126 replaceCellElement: (
127 coord: CellKey,
128 elementIndex: number,
129 newElement: CellElement
130 ) => void;
131
132 setColumnSortable: (
133 column: number,
134 sortable: SortableType | undefined
135 ) => void;
136 setColumnWidth: (column: number, width: string | undefined) => void;
137 insertRow: (rowIndex: number) => void;
138 deleteRow: (rowIndex: number) => void;
139 insertColumn: (columnIndex: number) => void;
140 deleteColumn: (columnIndex: number) => void;
141 duplicateRow: (rowIndex: number) => void;
142 duplicateColumn: (columnIndex: number) => void;
143 moveRow: (subjectRow: number, targetRow: number) => void;
144 moveColumn: (subjectColumn: number, targetColumn: number) => void;
145 getColumnConfig: (column: number) => ColumnConfig | undefined;
146 setRowHeight: (row: number, height: string | undefined) => void;
147 getRowConfig: (row: number) => RowConfig | undefined;
148 isColumnSortableAllowed: (column: number) => boolean;
149
150 enterSortPreviewMode: () => void;
151 exitSortPreviewMode: () => void;
152 setPreviewSort: (column: number | null, order?: SortOrder) => void;
153 togglePreviewSort: (column: number) => void;
154 getSortedRowIndices: () => number[];
155
156 setCurrentPage: (page: number) => void;
157 setPaginationConfig: (config: Partial<PaginationConfig>) => void;
158 isPaginationAllowed: () => boolean;
159
160 setSearchTerm: (term: string) => void;
161 getFilteredRowIndices: () => number[];
162 setShowCaption: (show: boolean) => void;
163 toggleRowColumnControls: () => void;
164 toggleDuplicateMoveControls: () => void;
165
166 setTableAttrs: (attrs: TablebergBlockAttrs) => void;
167 getTableAttrs: () => TablebergBlockAttrs;
168
169 setAttrVersion: (version: number) => void;
170
171 reset: () => void;
172 }
173
174 type TableStore = ReturnType<typeof createTableStore>;
175
176 type RowType = "header" | "footer" | "even" | "odd";
177 type HomogeneousRowTypeColors = Partial<Record<RowType, string>>;
178
179 function setCell(
180 coord: CellKey,
181 cell: Cell | undefined,
182 cells: Record<CellKey, Cell>
183 ): Record<CellKey, Cell> {
184 const nextCells = { ...cells };
185
186 if (
187 !cell ||
188 (cell.span === undefined &&
189 cell.elements === undefined &&
190 cell.styles === undefined &&
191 cell.ribbon === undefined)
192 ) {
193 delete nextCells[coord];
194 return nextCells;
195 }
196
197 nextCells[coord] = cell;
198 return nextCells;
199 }
200
201 function getCellRow(coord: CellKey): number {
202 return parseCellKey(coord)[0];
203 }
204
205 function getCellColumn(coord: CellKey): number {
206 return parseCellKey(coord)[1];
207 }
208
209 function getRowSiblingCellCoordsFromCells(
210 cells: Record<CellKey, Cell>,
211 rowIndices: number[]
212 ): CellKey[] {
213 if (rowIndices.length === 0) {
214 return [];
215 }
216
217 const rowSet = new Set(rowIndices);
218
219 return Object.keys(cells).filter(key =>
220 rowSet.has(parseCellKey(key)[0])
221 ) as CellKey[];
222 }
223
224 function getCellStyleOverrides(cells: Record<CellKey, Cell>) {
225 const styleMap = new Map<string, Partial<TableCellStylesType>>();
226
227 Object.entries(cells).forEach(([key, cell]) => {
228 if (cell.styles) {
229 styleMap.set(key, cell.styles);
230 }
231 });
232
233 return styleMap;
234 }
235
236 function collectUsedBindingIds(cells: Record<CellKey, Cell>): Set<string> {
237 const used = new Set<string>();
238
239 Object.values(cells).forEach(cell => {
240 const elements = cell.elements || [];
241 elements.forEach(element => {
242 Object.values(element.bindings || {}).forEach(bindingId => {
243 if (bindingId) {
244 used.add(bindingId);
245 }
246 });
247 });
248 });
249
250 return used;
251 }
252
253 function pruneUnusedBindings(
254 bindings: Record<string, BindingDefinition>,
255 cells: Record<CellKey, Cell>
256 ): Record<string, BindingDefinition> {
257 const usedBindingIds = collectUsedBindingIds(cells);
258 const nextBindings: Record<string, BindingDefinition> = {};
259
260 Object.entries(bindings).forEach(([bindingId, binding]) => {
261 if (usedBindingIds.has(bindingId)) {
262 nextBindings[bindingId] = binding;
263 }
264 });
265
266 return nextBindings;
267 }
268
269 const getRowType = (row: number, rows: number, table: TableConfig): RowType => {
270 if (table.headerEnabled && row === 0) {
271 return "header";
272 }
273
274 if (table.footerEnabled && row === rows - 1) {
275 return "footer";
276 }
277
278 const adjustedRow = table.headerEnabled ? row - 1 : row;
279
280 return adjustedRow % 2 === 0 ? "odd" : "even";
281 };
282
283 const getCommonBackgroundColor = (
284 coords: CellKey[],
285 cellStyleMap: Map<string, Partial<TableCellStylesType>>,
286 globalBackgroundColor: string
287 ): string | undefined => {
288 if (coords.length === 0) {
289 return undefined;
290 }
291
292 let commonColor: string | undefined;
293 let hasExplicitColor = false;
294
295 for (const coord of coords) {
296 const cellStyle = cellStyleMap.get(coord);
297 const explicitBackgroundColor = cellStyle?.backgroundColor?.trim();
298
299 if (explicitBackgroundColor) {
300 hasExplicitColor = true;
301 }
302
303 const resolvedBackgroundColor =
304 explicitBackgroundColor || globalBackgroundColor;
305
306 if (commonColor === undefined) {
307 commonColor = resolvedBackgroundColor;
308 continue;
309 }
310
311 if (commonColor !== resolvedBackgroundColor) {
312 return undefined;
313 }
314 }
315
316 if (!commonColor || !hasExplicitColor) {
317 return undefined;
318 }
319
320 return commonColor;
321 };
322
323 const getHomogeneousRowTypeColors = ({
324 table,
325 cells,
326 cellDefaults,
327 }: {
328 table: TableConfig;
329 cells: Record<CellKey, Cell>;
330 cellDefaults: TablebergBlockAttrs["cellDefaults"];
331 }): HomogeneousRowTypeColors => {
332 const styleMap = getCellStyleOverrides(cells);
333
334 const rowTypes: RowType[] = ["header", "even", "odd", "footer"];
335 const colors: HomogeneousRowTypeColors = {};
336
337 rowTypes.forEach(rowType => {
338 const rowIndices: number[] = [];
339
340 for (let row = 0; row < table.rows; row++) {
341 if (getRowType(row, table.rows, table) === rowType) {
342 rowIndices.push(row);
343 }
344 }
345
346 const color = getCommonBackgroundColor(
347 getRowSiblingCellCoordsFromCells(cells, rowIndices),
348 styleMap,
349 cellDefaults.styles.backgroundColor
350 );
351
352 if (color) {
353 colors[rowType] = color;
354 }
355 });
356
357 return colors;
358 };
359
360 const applyRowTypeColorsToCells = (
361 cells: Record<CellKey, Cell>,
362 targetCells: CellKey[],
363 totalRows: number,
364 table: TableConfig,
365 rowTypeColors: HomogeneousRowTypeColors
366 ): Record<CellKey, Cell> => {
367 if (targetCells.length === 0) {
368 return cells;
369 }
370
371 let nextCells = { ...cells };
372
373 targetCells.forEach(coord => {
374 const [row] = parseCellKey(coord);
375 const rowType = getRowType(row, totalRows, table);
376 const color = rowTypeColors[rowType];
377
378 if (!color) {
379 return;
380 }
381
382 const existingCell = nextCells[coord] || {};
383 nextCells = setCell(
384 coord,
385 {
386 ...existingCell,
387 styles: {
388 ...(existingCell.styles || {}),
389 backgroundColor: color,
390 },
391 },
392 nextCells
393 );
394 });
395
396 return nextCells;
397 };
398
399 const getRowsToRecolorAfterInsert = (
400 insertAt: number,
401 previousRowCount: number,
402 tableConfig: TableConfig,
403 rowTypeColors: HomogeneousRowTypeColors
404 ): number[] => {
405 const rowsToRecolor = new Set<number>();
406 const nextRowCount = previousRowCount + 1;
407
408 const insertedRowType = getRowType(insertAt, nextRowCount, tableConfig);
409 if (rowTypeColors[insertedRowType]) {
410 rowsToRecolor.add(insertAt);
411 }
412
413 for (let row = insertAt; row < previousRowCount; row++) {
414 const shiftedRow = row + 1;
415 const previousRowType = getRowType(row, previousRowCount, tableConfig);
416 const shiftedRowType = getRowType(
417 shiftedRow,
418 nextRowCount,
419 tableConfig
420 );
421
422 if (
423 previousRowType !== shiftedRowType &&
424 rowTypeColors[shiftedRowType]
425 ) {
426 rowsToRecolor.add(shiftedRow);
427 }
428 }
429
430 return Array.from(rowsToRecolor);
431 };
432
433 function createTableStore(initialValues?: TablebergBlockAttrs) {
434 return create<TableState>((set, get) => {
435 const attrs = initialValues || attrDefaults;
436 const initialState = {
437 ...attrs,
438 selectedCells: [] as Array<CellKey>,
439 nativeSelectedCells: [] as string[],
440 selectedElement: null as SelectedElement | null,
441 selectedRibbonCell: null as CellKey | null,
442 tableEditPreview: null as TableEditPreview,
443 sortPreviewMode: false,
444 previewSortColumn: null as number | null,
445 previewSortOrder: "asc" as SortOrder,
446 currentPage: 0,
447 searchTerm: "",
448 showCaption: !!(attrs.table.caption || ""),
449 showRowColumnControls: true,
450 showDuplicateMoveControls: true,
451 };
452
453 return {
454 ...initialState,
455
456 setTable: table => set({ table }),
457 setCells: cells =>
458 set(state => ({
459 cells,
460 bindings: pruneUnusedBindings(state.bindings, cells),
461 })),
462 setCellDefaults: cellDefaults => set({ cellDefaults }),
463 setBindings: bindings => set({ bindings }),
464
465 updateTable: updates =>
466 set(state => ({
467 table: { ...state.table, ...updates },
468 })),
469
470 updateCellGlobalStyles: updates =>
471 set(state => {
472 const updatedKeys = Object.keys(updates) as Array<
473 keyof TableCellStylesType
474 >;
475 const cells = Object.fromEntries(
476 Object.entries(state.cells).map(([key, cell]) => {
477 if (!cell.styles) {
478 return [key, cell];
479 }
480
481 const nextStyles = { ...cell.styles };
482 updatedKeys.forEach(styleKey => {
483 delete nextStyles[styleKey];
484 });
485
486 return [
487 key,
488 {
489 ...cell,
490 styles:
491 Object.keys(nextStyles).length > 0
492 ? nextStyles
493 : undefined,
494 },
495 ];
496 })
497 ) as Record<CellKey, Cell>;
498
499 return {
500 cells,
501 cellDefaults: {
502 styles: {
503 ...state.cellDefaults.styles,
504 ...updates,
505 },
506 },
507 };
508 }),
509
510 updateCellStyles: (coord, updates) => {
511 set(state => {
512 const existingCell = state.cells[coord] || {};
513 return {
514 cells: setCell(
515 coord,
516 {
517 ...existingCell,
518 styles: {
519 ...(existingCell.styles || {}),
520 ...updates,
521 },
522 },
523 state.cells
524 ),
525 };
526 });
527 },
528
529 getCellStyle: coord => {
530 return get().cells[coord]?.styles;
531 },
532
533 getCellSpan: coord => {
534 return (
535 get().cells[coord]?.span || {
536 rowSpan: 1,
537 colSpan: 1,
538 }
539 );
540 },
541
542 getCellRibbon: coord => {
543 return get().cells[coord]?.ribbon;
544 },
545
546 setCellRibbon: (coord, ribbon) => {
547 set(state => {
548 const existingCell = state.cells[coord] || {};
549 return {
550 cells: setCell(
551 coord,
552 {
553 ...existingCell,
554 ribbon,
555 },
556 state.cells
557 ),
558 };
559 });
560 },
561
562 addElementToCell: (coord, element) => {
563 set(state => {
564 const nextElements = [
565 ...(state.cells[coord]?.elements || []),
566 element,
567 ];
568 let nextElementIndex = 0;
569 nextElementIndex = nextElements.length - 1;
570 const cells = setCell(
571 coord,
572 {
573 ...(state.cells[coord] || {}),
574 elements: nextElements,
575 },
576 state.cells
577 );
578
579 return {
580 cells,
581 selectedElement: {
582 cell: coord,
583 elementIndex: nextElementIndex,
584 },
585 selectedRibbonCell: null,
586 };
587 });
588 },
589
590 insertElementInCell: (coord, elementIndex, element) => {
591 set(state => {
592 const existingCell = state.cells[coord];
593 const cellElements = [
594 ...(state.cells[coord]?.elements || []),
595 ];
596
597 if (!existingCell && elementIndex !== 0) {
598 return state;
599 }
600
601 if (cellElements.length === 0 && elementIndex === 0) {
602 const cells = setCell(
603 coord,
604 { ...(existingCell || {}), elements: [element] },
605 state.cells
606 );
607
608 return {
609 cells,
610 selectedElement: {
611 cell: coord,
612 elementIndex: 0,
613 },
614 selectedRibbonCell: null,
615 };
616 }
617
618 if (cellElements.length === 0) {
619 if (elementIndex !== 0) {
620 return state;
621 }
622 }
623 const insertAt = Math.max(
624 0,
625 Math.min(elementIndex, cellElements.length)
626 );
627
628 cellElements.splice(insertAt, 0, element);
629 const cells = setCell(
630 coord,
631 { ...(existingCell || {}), elements: cellElements },
632 state.cells
633 );
634
635 return {
636 cells,
637 selectedElement: {
638 cell: coord,
639 elementIndex: insertAt,
640 },
641 selectedRibbonCell: null,
642 };
643 });
644 },
645
646 reorderElementsInCell: (coord, sourceIndicesInNextOrder) => {
647 set(state => {
648 const existingCell = state.cells[coord];
649 if (!existingCell) {
650 return state;
651 }
652
653 const cellElements = [...(existingCell.elements || [])];
654
655 if (
656 cellElements.length !== sourceIndicesInNextOrder.length
657 ) {
658 return state;
659 }
660
661 const validIndices = new Set<number>();
662
663 for (const sourceIndex of sourceIndicesInNextOrder) {
664 if (
665 sourceIndex < 0 ||
666 sourceIndex >= cellElements.length ||
667 validIndices.has(sourceIndex)
668 ) {
669 return state;
670 }
671
672 validIndices.add(sourceIndex);
673 }
674
675 const reorderedElements = sourceIndicesInNextOrder.map(
676 sourceIndex => cellElements[sourceIndex]
677 );
678 const didChangeOrder = reorderedElements.some(
679 (element, index) => element !== cellElements[index]
680 );
681
682 if (!didChangeOrder) {
683 return state;
684 }
685
686 const cells = setCell(
687 coord,
688 { ...existingCell, elements: reorderedElements },
689 state.cells
690 );
691
692 let nextSelectedElement = state.selectedElement;
693
694 if (
695 nextSelectedElement &&
696 nextSelectedElement.cell === coord
697 ) {
698 const nextIndex = sourceIndicesInNextOrder.findIndex(
699 sourceIndex =>
700 sourceIndex ===
701 nextSelectedElement?.elementIndex
702 );
703
704 nextSelectedElement =
705 nextIndex === -1
706 ? null
707 : {
708 cell: coord,
709 elementIndex: nextIndex,
710 };
711 }
712
713 return {
714 cells,
715 selectedElement: nextSelectedElement,
716 };
717 });
718 },
719
720 duplicateElementInCell: (coord, elementIndex) => {
721 set(state => {
722 const existingCell = state.cells[coord];
723 if (!existingCell) return state;
724
725 const cellElements = [...(existingCell.elements || [])];
726 const sourceElement = cellElements[elementIndex];
727
728 if (!sourceElement) {
729 return state;
730 }
731
732 const nextElementIndex = elementIndex + 1;
733 cellElements.splice(
734 nextElementIndex,
735 0,
736 cloneValue(sourceElement) as CellElement
737 );
738 const cells = setCell(
739 coord,
740 { ...existingCell, elements: cellElements },
741 state.cells
742 );
743
744 return {
745 cells,
746 selectedElement: {
747 cell: coord,
748 elementIndex: nextElementIndex,
749 },
750 selectedRibbonCell: null,
751 };
752 });
753 },
754
755 removeElementFromCell: (coord, elementIndex) => {
756 set(state => {
757 const existingCell = state.cells[coord];
758 if (!existingCell) return state;
759
760 const cellElements = [...(existingCell.elements || [])];
761 if (
762 elementIndex < 0 ||
763 elementIndex >= cellElements.length
764 ) {
765 return state;
766 }
767
768 cellElements.splice(elementIndex, 1);
769 const cells = setCell(
770 coord,
771 { ...existingCell, elements: cellElements },
772 state.cells
773 );
774
775 let nextSelectedElement = state.selectedElement;
776 if (
777 nextSelectedElement &&
778 nextSelectedElement.cell === coord
779 ) {
780 if (nextSelectedElement.elementIndex === elementIndex) {
781 nextSelectedElement = null;
782 } else if (
783 nextSelectedElement.elementIndex > elementIndex
784 ) {
785 nextSelectedElement = {
786 cell: nextSelectedElement.cell,
787 elementIndex:
788 nextSelectedElement.elementIndex - 1,
789 };
790 }
791 }
792
793 const bindings = pruneUnusedBindings(state.bindings, cells);
794
795 return {
796 cells,
797 bindings,
798 selectedElement: nextSelectedElement,
799 };
800 });
801 },
802
803 updateCellElement: (coord, elementIndex, updates) => {
804 set(state => {
805 const existingCell = state.cells[coord];
806 if (!existingCell) return state;
807
808 const cellElements = [...(existingCell.elements || [])];
809 if (elementIndex >= cellElements.length) return state;
810
811 cellElements[elementIndex] = {
812 ...cellElements[elementIndex],
813 attributes: {
814 ...cellElements[elementIndex].attributes,
815 ...updates.attributes,
816 },
817 } as CellElement;
818 return {
819 cells: setCell(
820 coord,
821 { ...existingCell, elements: cellElements },
822 state.cells
823 ),
824 };
825 });
826 },
827
828 updateSelectedElementStyles: styles => {
829 const { selectedElement } = get();
830 if (!selectedElement) return;
831
832 set(state => {
833 const existingCell = state.cells[selectedElement.cell];
834 if (!existingCell) return state;
835
836 const cellElements = [...(existingCell.elements || [])];
837 const elementIndex = selectedElement.elementIndex;
838 if (elementIndex >= cellElements.length) return state;
839
840 const element = cellElements[elementIndex];
841 const currentStyles =
842 (
843 element.attributes as {
844 styles?: Record<string, unknown>;
845 }
846 ).styles || {};
847
848 cellElements[elementIndex] = {
849 ...element,
850 attributes: {
851 ...element.attributes,
852 styles: {
853 ...currentStyles,
854 ...styles,
855 },
856 },
857 } as CellElement;
858 return {
859 cells: setCell(
860 selectedElement.cell,
861 { ...existingCell, elements: cellElements },
862 state.cells
863 ),
864 };
865 });
866 },
867
868 updateSelectedElementAttrs: attrs => {
869 const { selectedElement } = get();
870 if (!selectedElement) return;
871
872 set(state => {
873 const existingCell = state.cells[selectedElement.cell];
874 if (!existingCell) return state;
875
876 const cellElements = [...(existingCell.elements || [])];
877 const elementIndex = selectedElement.elementIndex;
878 if (elementIndex >= cellElements.length) return state;
879
880 const element = cellElements[elementIndex];
881
882 cellElements[elementIndex] = {
883 ...element,
884 attributes: {
885 ...element.attributes,
886 ...attrs,
887 },
888 } as CellElement;
889 return {
890 cells: setCell(
891 selectedElement.cell,
892 { ...existingCell, elements: cellElements },
893 state.cells
894 ),
895 };
896 });
897 },
898
899 updateSelectedElementBindings: bindings => {
900 const { selectedElement } = get();
901 if (!selectedElement) return;
902
903 set(state => {
904 const existingCell = state.cells[selectedElement.cell];
905 if (!existingCell) return state;
906
907 const cellElements = [...(existingCell.elements || [])];
908 const elementIndex = selectedElement.elementIndex;
909 if (elementIndex >= cellElements.length) return state;
910
911 const element = cellElements[elementIndex];
912
913 const updatedElement = {
914 ...element,
915 bindings,
916 };
917
918 if (bindings === undefined) {
919 delete (
920 updatedElement as { bindings?: ElementBindings }
921 ).bindings;
922 }
923
924 cellElements[elementIndex] = updatedElement as CellElement;
925 const cells = setCell(
926 selectedElement.cell,
927 { ...existingCell, elements: cellElements },
928 state.cells
929 );
930 const nextBindings = pruneUnusedBindings(
931 state.bindings,
932 cells
933 );
934
935 return {
936 cells,
937 bindings: nextBindings,
938 };
939 });
940 },
941
942 createBindingDefinition: binding => {
943 const bindingId = `${Math.random().toString(36).slice(2, 10)}`;
944 set(state => ({
945 bindings: {
946 ...state.bindings,
947 [bindingId]: binding,
948 },
949 }));
950 return bindingId;
951 },
952
953 updateBindingDefinition: (bindingId, binding) => {
954 set(state => ({
955 bindings: {
956 ...state.bindings,
957 [bindingId]: binding,
958 },
959 }));
960 },
961
962 removeBindingDefinition: bindingId => {
963 set(state => {
964 const bindings = { ...state.bindings };
965 delete bindings[bindingId];
966 return { bindings };
967 });
968 },
969
970 replaceCellElement: (coord, elementIndex, newElement) => {
971 set(state => {
972 const existingCell = state.cells[coord];
973 if (!existingCell) return state;
974
975 const cellElements = [...(existingCell.elements || [])];
976 if (elementIndex >= cellElements.length) return state;
977
978 cellElements[elementIndex] = newElement;
979 const cells = setCell(
980 coord,
981 { ...existingCell, elements: cellElements },
982 state.cells
983 );
984 const bindings = pruneUnusedBindings(state.bindings, cells);
985
986 return {
987 cells,
988 bindings,
989 };
990 });
991 },
992
993 setSelectedCells: coords =>
994 set(state => {
995 if (
996 state.selectedCells.length === coords.length &&
997 state.selectedCells.every(
998 (coord, index) => coord === coords[index]
999 )
1000 ) {
1001 return state;
1002 }
1003
1004 return { selectedCells: coords };
1005 }),
1006
1007 setNativeSelectedCells: clientIds =>
1008 set(state => {
1009 if (
1010 state.nativeSelectedCells.length ===
1011 clientIds.length &&
1012 state.nativeSelectedCells.every(
1013 (id, index) => id === clientIds[index]
1014 )
1015 ) {
1016 return state;
1017 }
1018
1019 return { nativeSelectedCells: clientIds };
1020 }),
1021
1022 setSelectedElement: (cell, elementIndex) => {
1023 set(state => {
1024 if (
1025 state.selectedElement?.cell === cell &&
1026 state.selectedElement.elementIndex === elementIndex
1027 ) {
1028 return state;
1029 }
1030
1031 return { selectedElement: { cell, elementIndex } };
1032 });
1033 },
1034
1035 clearSelectedElement: () => {
1036 set(state => {
1037 if (state.selectedElement === null) {
1038 return state;
1039 }
1040
1041 return { selectedElement: null };
1042 });
1043 },
1044
1045 isElementSelected: (cell, elementIndex) => {
1046 const { selectedElement } = get();
1047 return (
1048 selectedElement !== null &&
1049 selectedElement.cell === cell &&
1050 selectedElement.elementIndex === elementIndex
1051 );
1052 },
1053
1054 setSelectedRibbon: cell => {
1055 set(state => {
1056 if (
1057 state.selectedRibbonCell === cell &&
1058 state.selectedElement === null
1059 ) {
1060 return state;
1061 }
1062
1063 return { selectedRibbonCell: cell, selectedElement: null };
1064 });
1065 },
1066
1067 clearSelectedRibbon: () => {
1068 set(state => {
1069 if (state.selectedRibbonCell === null) {
1070 return state;
1071 }
1072
1073 return { selectedRibbonCell: null };
1074 });
1075 },
1076
1077 isRibbonSelected: cell => {
1078 const { selectedRibbonCell } = get();
1079 return (
1080 selectedRibbonCell !== null && selectedRibbonCell === cell
1081 );
1082 },
1083
1084 setTableEditPreview: tableEditPreview => {
1085 set(state => {
1086 const current = state.tableEditPreview;
1087
1088 if (
1089 current?.operation === tableEditPreview?.operation &&
1090 current?.target === tableEditPreview?.target &&
1091 current?.index === tableEditPreview?.index
1092 ) {
1093 return state;
1094 }
1095
1096 if (current === null && tableEditPreview === null) {
1097 return state;
1098 }
1099
1100 return { tableEditPreview };
1101 });
1102 },
1103
1104 clearTableEditPreview: () => {
1105 set(state => {
1106 if (state.tableEditPreview === null) {
1107 return state;
1108 }
1109
1110 return { tableEditPreview: null };
1111 });
1112 },
1113
1114 addSelectedCells: coords =>
1115 set(state => {
1116 // Toggle each coord: add if absent, remove if already
1117 // selected. Keeps the selection free of duplicates so
1118 // building a multi-cell selection for merging is reliable.
1119 const next = [...state.selectedCells];
1120
1121 for (const coord of coords) {
1122 const existingIndex = next.indexOf(coord);
1123
1124 if (existingIndex === -1) {
1125 next.push(coord);
1126 } else {
1127 next.splice(existingIndex, 1);
1128 }
1129 }
1130
1131 return { selectedCells: next };
1132 }),
1133
1134 setColumnSortable: (column, sortable) => {
1135 set(state => {
1136 // columns is an ARRAY — spreading it into an object
1137 // turned it into an indexed map, which then crashed
1138 // every columns.some() consumer.
1139 const columns = state.columns.slice();
1140 if (sortable === undefined) {
1141 if (columns[column]) {
1142 const { sortable: _, ...rest } = columns[column]!;
1143 columns[column] =
1144 Object.keys(rest).length === 0 ? null : rest;
1145 }
1146 } else {
1147 columns[column] = { ...columns[column], sortable };
1148 }
1149 return { columns };
1150 });
1151 },
1152
1153 setColumnWidth: (column, width) => {
1154 set(state => {
1155 const columns = state.columns.slice();
1156 const normalizedWidth = width?.trim();
1157
1158 if (!normalizedWidth) {
1159 if (columns[column]) {
1160 const { width: _, ...rest } = columns[column];
1161 if (Object.keys(rest).length === 0) {
1162 delete columns[column];
1163 } else {
1164 columns[column] = rest;
1165 }
1166 }
1167 } else {
1168 columns[column] = {
1169 ...columns[column],
1170 width: normalizedWidth,
1171 };
1172 }
1173
1174 return { columns };
1175 });
1176 },
1177
1178 insertRow: rowIndex => {
1179 set(state => {
1180 const insertAt = Math.max(
1181 0,
1182 Math.min(rowIndex, state.table.rows)
1183 );
1184 const rowTypeColors = getHomogeneousRowTypeColors({
1185 table: state.table,
1186 cells: state.cells,
1187 cellDefaults: state.cellDefaults,
1188 });
1189 const textElement = createElement("text") ?? undefined;
1190 const nextState = insertRowAt(
1191 state.table,
1192 state.rows,
1193 state.columns,
1194 state.cells,
1195 insertAt,
1196 textElement
1197 );
1198 const rowsToRecolor = getRowsToRecolorAfterInsert(
1199 insertAt,
1200 state.table.rows,
1201 state.table,
1202 rowTypeColors
1203 );
1204 const recoloredCells = applyRowTypeColorsToCells(
1205 nextState.cells,
1206 getRowSiblingCellCoordsFromCells(
1207 nextState.cells,
1208 rowsToRecolor
1209 ),
1210 nextState.table.rows,
1211 state.table,
1212 rowTypeColors
1213 );
1214
1215 const selectedCol =
1216 state.selectedCells.length > 0
1217 ? getCellColumn(state.selectedCells[0])
1218 : 0;
1219 const nextSelectedCol = Math.max(
1220 0,
1221 Math.min(selectedCol, nextState.table.cols - 1)
1222 );
1223
1224 return {
1225 table: nextState.table,
1226 rows: nextState.rows,
1227 columns: nextState.columns,
1228 cells: recoloredCells,
1229 selectedCells: [getCellKey(insertAt, nextSelectedCol)],
1230 selectedElement: null,
1231 selectedRibbonCell: null,
1232 };
1233 });
1234 },
1235
1236 deleteRow: rowIndex => {
1237 set(state => {
1238 if (state.table.rows <= 1) {
1239 return state;
1240 }
1241
1242 const deleteAt = Math.max(
1243 0,
1244 Math.min(rowIndex, state.table.rows - 1)
1245 );
1246 const nextState = deleteRowAt(
1247 state.table,
1248 state.rows,
1249 state.columns,
1250 state.cells,
1251 deleteAt
1252 );
1253
1254 const selectedCol =
1255 state.selectedCells.length > 0
1256 ? getCellColumn(state.selectedCells[0])
1257 : 0;
1258 const nextSelectedCol = Math.max(
1259 0,
1260 Math.min(selectedCol, nextState.table.cols - 1)
1261 );
1262 const nextSelectedRow = Math.max(
1263 0,
1264 Math.min(deleteAt, nextState.table.rows - 1)
1265 );
1266
1267 return {
1268 table: nextState.table,
1269 rows: nextState.rows,
1270 columns: nextState.columns,
1271 cells: nextState.cells,
1272 bindings: pruneUnusedBindings(
1273 state.bindings,
1274 nextState.cells
1275 ),
1276 selectedCells: [
1277 getCellKey(nextSelectedRow, nextSelectedCol),
1278 ],
1279 selectedElement: null,
1280 selectedRibbonCell: null,
1281 };
1282 });
1283 },
1284
1285 insertColumn: columnIndex => {
1286 set(state => {
1287 const insertAt = Math.max(
1288 0,
1289 Math.min(columnIndex, state.table.cols)
1290 );
1291 const rowTypeColors = getHomogeneousRowTypeColors({
1292 table: state.table,
1293 cells: state.cells,
1294 cellDefaults: state.cellDefaults,
1295 });
1296 const textElement = createElement("text") ?? undefined;
1297 const nextState = insertColumnAt(
1298 state.table,
1299 state.rows,
1300 state.columns,
1301 state.cells,
1302 insertAt,
1303 textElement
1304 );
1305 const insertedColumnCells = Object.keys(
1306 nextState.cells
1307 ).filter(
1308 key => getCellColumn(key as CellKey) === insertAt
1309 ) as CellKey[];
1310 const recoloredCells = applyRowTypeColorsToCells(
1311 nextState.cells,
1312 insertedColumnCells,
1313 nextState.table.rows,
1314 state.table,
1315 rowTypeColors
1316 );
1317
1318 const selectedRow =
1319 state.selectedCells.length > 0
1320 ? getCellRow(state.selectedCells[0])
1321 : 0;
1322 const nextSelectedRow = Math.max(
1323 0,
1324 Math.min(selectedRow, nextState.table.rows - 1)
1325 );
1326
1327 return {
1328 table: nextState.table,
1329 rows: nextState.rows,
1330 columns: nextState.columns,
1331 cells: recoloredCells,
1332 selectedCells: [getCellKey(nextSelectedRow, insertAt)],
1333 selectedElement: null,
1334 selectedRibbonCell: null,
1335 };
1336 });
1337 },
1338
1339 deleteColumn: columnIndex => {
1340 set(state => {
1341 if (state.table.cols <= 1) {
1342 return state;
1343 }
1344
1345 const deleteAt = Math.max(
1346 0,
1347 Math.min(columnIndex, state.table.cols - 1)
1348 );
1349 const nextState = deleteColumnAt(
1350 state.table,
1351 state.rows,
1352 state.columns,
1353 state.cells,
1354 deleteAt
1355 );
1356
1357 const selectedRow =
1358 state.selectedCells.length > 0
1359 ? getCellRow(state.selectedCells[0])
1360 : 0;
1361 const nextSelectedRow = Math.max(
1362 0,
1363 Math.min(selectedRow, nextState.table.rows - 1)
1364 );
1365 const nextSelectedCol = Math.max(
1366 0,
1367 Math.min(deleteAt, nextState.table.cols - 1)
1368 );
1369
1370 return {
1371 table: nextState.table,
1372 rows: nextState.rows,
1373 columns: nextState.columns,
1374 cells: nextState.cells,
1375 bindings: pruneUnusedBindings(
1376 state.bindings,
1377 nextState.cells
1378 ),
1379 selectedCells: [
1380 getCellKey(nextSelectedRow, nextSelectedCol),
1381 ],
1382 selectedElement: null,
1383 selectedRibbonCell: null,
1384 };
1385 });
1386 },
1387
1388 duplicateRow: rowIndex => {
1389 set(state => {
1390 if (hasMergedCells(state.cells)) {
1391 return state;
1392 }
1393
1394 const sourceRow = Math.max(
1395 0,
1396 Math.min(rowIndex, state.table.rows - 1)
1397 );
1398
1399 const nextState = duplicateRowAt(
1400 state.table,
1401 state.rows,
1402 state.columns,
1403 state.cells,
1404 sourceRow
1405 );
1406
1407 const selectedCol =
1408 state.selectedCells.length > 0
1409 ? getCellColumn(state.selectedCells[0])
1410 : 0;
1411 const nextSelectedCol = Math.max(
1412 0,
1413 Math.min(selectedCol, nextState.table.cols - 1)
1414 );
1415
1416 return {
1417 table: nextState.table,
1418 rows: nextState.rows,
1419 columns: nextState.columns,
1420 cells: nextState.cells,
1421 selectedCells: [
1422 getCellKey(sourceRow + 1, nextSelectedCol),
1423 ],
1424 selectedElement: null,
1425 selectedRibbonCell: null,
1426 };
1427 });
1428 },
1429
1430 duplicateColumn: columnIndex => {
1431 set(state => {
1432 if (hasMergedCells(state.cells)) {
1433 return state;
1434 }
1435
1436 const sourceColumn = Math.max(
1437 0,
1438 Math.min(columnIndex, state.table.cols - 1)
1439 );
1440
1441 const nextState = duplicateColumnAt(
1442 state.table,
1443 state.rows,
1444 state.columns,
1445 state.cells,
1446 sourceColumn
1447 );
1448
1449 const selectedRow =
1450 state.selectedCells.length > 0
1451 ? getCellRow(state.selectedCells[0])
1452 : 0;
1453 const nextSelectedRow = Math.max(
1454 0,
1455 Math.min(selectedRow, nextState.table.rows - 1)
1456 );
1457
1458 return {
1459 table: nextState.table,
1460 rows: nextState.rows,
1461 columns: nextState.columns,
1462 cells: nextState.cells,
1463 selectedCells: [
1464 getCellKey(nextSelectedRow, sourceColumn + 1),
1465 ],
1466 selectedElement: null,
1467 selectedRibbonCell: null,
1468 };
1469 });
1470 },
1471
1472 moveRow: (subjectRow, targetRow) => {
1473 set(state => {
1474 if (hasMergedCells(state.cells)) {
1475 return state;
1476 }
1477
1478 if (
1479 subjectRow < 0 ||
1480 subjectRow >= state.table.rows ||
1481 targetRow < 0 ||
1482 targetRow >= state.table.rows
1483 ) {
1484 return state;
1485 }
1486
1487 const nextState = moveRowTo(
1488 state.table,
1489 state.rows,
1490 state.columns,
1491 state.cells,
1492 subjectRow,
1493 targetRow
1494 );
1495
1496 const selectedCol =
1497 state.selectedCells.length > 0
1498 ? getCellColumn(state.selectedCells[0])
1499 : 0;
1500 const nextSelectedCol = Math.max(
1501 0,
1502 Math.min(selectedCol, nextState.table.cols - 1)
1503 );
1504
1505 return {
1506 table: nextState.table,
1507 rows: nextState.rows,
1508 columns: nextState.columns,
1509 cells: nextState.cells,
1510 selectedCells: [getCellKey(targetRow, nextSelectedCol)],
1511 selectedElement: null,
1512 selectedRibbonCell: null,
1513 };
1514 });
1515 },
1516
1517 moveColumn: (subjectColumn, targetColumn) => {
1518 set(state => {
1519 if (hasMergedCells(state.cells)) {
1520 return state;
1521 }
1522
1523 if (
1524 subjectColumn < 0 ||
1525 subjectColumn >= state.table.cols ||
1526 targetColumn < 0 ||
1527 targetColumn >= state.table.cols
1528 ) {
1529 return state;
1530 }
1531
1532 const nextState = moveColumnTo(
1533 state.table,
1534 state.rows,
1535 state.columns,
1536 state.cells,
1537 subjectColumn,
1538 targetColumn
1539 );
1540
1541 const selectedRow =
1542 state.selectedCells.length > 0
1543 ? getCellRow(state.selectedCells[0])
1544 : 0;
1545 const nextSelectedRow = Math.max(
1546 0,
1547 Math.min(selectedRow, nextState.table.rows - 1)
1548 );
1549
1550 return {
1551 table: nextState.table,
1552 rows: nextState.rows,
1553 columns: nextState.columns,
1554 cells: nextState.cells,
1555 selectedCells: [
1556 getCellKey(nextSelectedRow, targetColumn),
1557 ],
1558 selectedElement: null,
1559 selectedRibbonCell: null,
1560 };
1561 });
1562 },
1563
1564 getColumnConfig: column => {
1565 return get().columns[column] || undefined;
1566 },
1567
1568 setRowHeight: (row, height) => {
1569 set(state => {
1570 const rowConfigs = state.rows.slice();
1571 const normalizedHeight = height?.trim();
1572
1573 if (!normalizedHeight) {
1574 if (rowConfigs[row]) {
1575 const { height: _, ...rest } = rowConfigs[row];
1576 if (Object.keys(rest).length === 0) {
1577 delete rowConfigs[row];
1578 } else {
1579 rowConfigs[row] = rest;
1580 }
1581 }
1582 } else {
1583 rowConfigs[row] = {
1584 ...rowConfigs[row],
1585 height: normalizedHeight,
1586 };
1587 }
1588
1589 return { rows: rowConfigs };
1590 });
1591 },
1592
1593 getRowConfig: row => {
1594 return get().rows[row] || undefined;
1595 },
1596
1597 isColumnSortableAllowed: column => {
1598 return isColumnSortable(column, get().cells);
1599 },
1600
1601 enterSortPreviewMode: () => {
1602 set({
1603 sortPreviewMode: true,
1604 previewSortColumn: null,
1605 previewSortOrder: "asc",
1606 selectedCells: [],
1607 });
1608 },
1609
1610 exitSortPreviewMode: () => {
1611 set({
1612 sortPreviewMode: false,
1613 previewSortColumn: null,
1614 previewSortOrder: "asc",
1615 });
1616 },
1617
1618 setPreviewSort: (column, order) => {
1619 set(state => ({
1620 previewSortColumn: column,
1621 previewSortOrder: order || state.previewSortOrder,
1622 }));
1623 },
1624
1625 togglePreviewSort: column => {
1626 set(state => {
1627 if (state.previewSortColumn === column) {
1628 if (state.previewSortOrder === "asc") {
1629 return { previewSortOrder: "desc" as SortOrder };
1630 } else {
1631 return {
1632 previewSortColumn: null,
1633 previewSortOrder: "asc" as SortOrder,
1634 };
1635 }
1636 }
1637 return {
1638 previewSortColumn: column,
1639 previewSortOrder: "asc" as SortOrder,
1640 };
1641 });
1642 },
1643
1644 getSortedRowIndices: () => {
1645 const {
1646 sortPreviewMode,
1647 previewSortColumn,
1648 previewSortOrder,
1649 table,
1650 columns,
1651 cells,
1652 } = get();
1653
1654 if (!sortPreviewMode || previewSortColumn === null) {
1655 return Array.from({ length: table.rows }, (_, i) => i);
1656 }
1657
1658 const sortType = columns[previewSortColumn]?.sortable || "text";
1659
1660 return sortRowsByColumn(
1661 cells,
1662 table.rows,
1663 table,
1664 previewSortColumn,
1665 sortType,
1666 previewSortOrder
1667 );
1668 },
1669
1670 setCurrentPage: page => {
1671 set({ currentPage: page });
1672 },
1673
1674 setPaginationConfig: config => {
1675 set(state => {
1676 const newPagination = {
1677 ...state.table.pagination!,
1678 ...config,
1679 };
1680
1681 const totalPages = getTotalPages(
1682 state.table.rows,
1683 newPagination.pageSize,
1684 state.table.headerEnabled,
1685 state.table.footerEnabled
1686 );
1687 const maxPage = Math.max(0, totalPages - 1);
1688 const currentPage = Math.min(state.currentPage, maxPage);
1689
1690 return {
1691 table: {
1692 ...state.table,
1693 pagination: newPagination,
1694 },
1695 currentPage,
1696 };
1697 });
1698 },
1699
1700 isPaginationAllowed: () => {
1701 return !tableHasRowSpanningCells(get().cells);
1702 },
1703
1704 setSearchTerm: term => {
1705 set(_ => {
1706 return {
1707 searchTerm: term,
1708 currentPage: 0,
1709 };
1710 });
1711 },
1712
1713 setShowCaption: show => {
1714 set(state => {
1715 if (state.showCaption === show) {
1716 return state;
1717 }
1718
1719 return { showCaption: show };
1720 });
1721 },
1722
1723 toggleRowColumnControls: () => {
1724 set(state => ({
1725 showRowColumnControls: !state.showRowColumnControls,
1726 }));
1727 },
1728
1729 toggleDuplicateMoveControls: () => {
1730 set(state => ({
1731 showDuplicateMoveControls: !state.showDuplicateMoveControls,
1732 }));
1733 },
1734
1735 getFilteredRowIndices: () => {
1736 const { searchTerm, table, cells } = get();
1737
1738 if (!table.search?.enabled || !searchTerm.trim()) {
1739 return Array.from({ length: table.rows }, (_, i) => i);
1740 }
1741
1742 return filterRowsBySearch(
1743 cells,
1744 table.rows,
1745 table.cols,
1746 table,
1747 searchTerm
1748 );
1749 },
1750
1751 setTableAttrs: attrs => set({ ...attrs }),
1752
1753 getTableAttrs: () => {
1754 const {
1755 version,
1756 isExample,
1757 table,
1758 rows,
1759 columns,
1760 cells,
1761 bindings,
1762 cellDefaults,
1763 } = get();
1764 return {
1765 version,
1766 isExample,
1767 table,
1768 rows,
1769 columns,
1770 cells,
1771 bindings,
1772 cellDefaults,
1773 };
1774 },
1775
1776 setAttrVersion: version =>
1777 set(state => {
1778 if (state.version === version) {
1779 return state;
1780 }
1781
1782 return { version };
1783 }),
1784
1785 reset: () => set(initialState),
1786 };
1787 });
1788 }
1789
1790 // Exported so the native editor's element bridge can mount a patched store
1791 // around reused element components (see src/native/element-bridge.tsx).
1792 export const TableStoreContext = createContext<TableStore | null>(null);
1793
1794 interface TableStoreProviderProps {
1795 children: ReactNode;
1796 clientId: string;
1797 attributes: TablebergBlockAttrs;
1798 setAttributes: (attrs: Partial<TablebergBlockAttrs>) => void;
1799 /**
1800 * Native-blocks mode: cell/row content lives in the block tree, not in
1801 * attrs. The store only mirrors table-level state (for the inspector
1802 * controls), so cells/rows must not sync back into the block attributes.
1803 */
1804 contentSyncDisabled?: boolean;
1805 }
1806
1807 // Stable fallbacks: fresh objects on every attributes-effect run would keep
1808 // changing state references and ping-pong with setAttributes into an
1809 // infinite update loop (React #185).
1810 const EMPTY_CELLS: TablebergBlockAttrs["cells"] = {};
1811 const EMPTY_ROWS: TablebergBlockAttrs["rows"] = [];
1812 const EMPTY_BINDINGS: TablebergBlockAttrs["bindings"] = {};
1813
1814 export function TableStoreProvider({
1815 children,
1816 attributes,
1817 setAttributes,
1818 contentSyncDisabled = false,
1819 }: TableStoreProviderProps) {
1820 const storeRef = useRef<TableStore | null>(null);
1821
1822 // Posts saved while the setColumnSortable object-spread bug was live have
1823 // columns as an indexed map instead of an array — normalize on the way in.
1824 const normalizeColumns = (
1825 columns: TablebergBlockAttrs["columns"] | undefined
1826 ): TablebergBlockAttrs["columns"] => {
1827 if (Array.isArray(columns)) {
1828 return columns;
1829 }
1830 if (!columns || typeof columns !== "object") {
1831 return [];
1832 }
1833 const result: TablebergBlockAttrs["columns"] = [];
1834 for (const [key, value] of Object.entries(columns)) {
1835 const index = Number(key);
1836 if (Number.isInteger(index) && index >= 0) {
1837 result[index] = value;
1838 }
1839 }
1840 return result;
1841 };
1842
1843 // v4 attrs carry no cells/rows (and may omit defaulted keys); the store
1844 // still expects the collections to exist.
1845 // Tables created with partial config (or by older builds) must still
1846 // satisfy controls that destructure pagination/search/responsive — but
1847 // only build a new object when keys are actually missing, so the
1848 // reference stays stable once the table is complete.
1849 const withTableDefaults = (
1850 table: TablebergBlockAttrs["table"] | undefined
1851 ): TablebergBlockAttrs["table"] => {
1852 if (!table) {
1853 return attrDefaults.table;
1854 }
1855 for (const key of Object.keys(attrDefaults.table)) {
1856 if (!(key in table)) {
1857 return { ...attrDefaults.table, ...table };
1858 }
1859 }
1860 return table;
1861 };
1862
1863 // Row/column counts are derived from the block tree, which the preview
1864 // snapshot keeps fresh; the counts in the attributes can be stale, so
1865 // never clobber the live ones. Returns the same reference when the
1866 // values already agree, so this converges instead of looping.
1867 const withLiveCounts = (
1868 table: TablebergBlockAttrs["table"],
1869 currentTable: TablebergBlockAttrs["table"] | undefined
1870 ): TablebergBlockAttrs["table"] => {
1871 if (
1872 !currentTable ||
1873 (table.rows === currentTable.rows &&
1874 table.cols === currentTable.cols)
1875 ) {
1876 return table;
1877 }
1878 return { ...table, rows: currentTable.rows, cols: currentTable.cols };
1879 };
1880
1881 // The store's initial state needs every key present.
1882 const initialAttrs = (attrs: TablebergBlockAttrs) =>
1883 contentSyncDisabled
1884 ? {
1885 ...attrs,
1886 table: withTableDefaults(attrs.table),
1887 cells: attrs.cells ?? EMPTY_CELLS,
1888 rows: attrs.rows ?? EMPTY_ROWS,
1889 columns: normalizeColumns(attrs.columns),
1890 bindings: attrs.bindings ?? EMPTY_BINDINGS,
1891 }
1892 : attrs;
1893
1894 // Applied on every attributes change. In native mode the cell content and
1895 // row configs live in the block tree (the preview snapshot writes them
1896 // into the store), so those keys must be left out entirely — writing them
1897 // from the attributes would wipe the snapshot, because effects run
1898 // child-first and this provider is the parent.
1899 const syncAttrs = (
1900 attrs: TablebergBlockAttrs,
1901 current: TableState
1902 ): Partial<TableState> => {
1903 if (!contentSyncDisabled) {
1904 return attrs;
1905 }
1906
1907 const { cells: _cells, rows: _rows, ...rest } = attrs;
1908
1909 return {
1910 ...rest,
1911 table: withLiveCounts(withTableDefaults(attrs.table), current.table),
1912 columns: normalizeColumns(attrs.columns),
1913 bindings: attrs.bindings ?? EMPTY_BINDINGS,
1914 };
1915 };
1916
1917 if (!storeRef.current) {
1918 storeRef.current = createTableStore(initialAttrs(attributes));
1919 }
1920
1921 useEffect(() => {
1922 if (storeRef.current) {
1923 storeRef.current.setState(
1924 syncAttrs(attributes, storeRef.current.getState())
1925 );
1926 }
1927 }, [attributes]);
1928
1929 useEffect(() => {
1930 if (storeRef.current) {
1931 const unsubscribe = storeRef.current.subscribe(state => {
1932 if (contentSyncDisabled) {
1933 setAttributes({
1934 version: state.version,
1935 isExample: state.isExample,
1936 table: state.table,
1937 columns: state.columns,
1938 bindings: state.bindings,
1939 cellDefaults: state.cellDefaults,
1940 });
1941 return;
1942 }
1943
1944 setAttributes({
1945 version: state.version,
1946 isExample: state.isExample,
1947 table: state.table,
1948 rows: state.rows,
1949 columns: state.columns,
1950 cells: state.cells,
1951 bindings: state.bindings,
1952 cellDefaults: state.cellDefaults,
1953 });
1954 });
1955 return unsubscribe;
1956 }
1957 }, [setAttributes, contentSyncDisabled]);
1958
1959 return (
1960 <TableStoreContext.Provider value={storeRef.current}>
1961 {children}
1962 </TableStoreContext.Provider>
1963 );
1964 }
1965
1966 export function useTableStore<T>(selector: (state: TableState) => T): T {
1967 const store = useContext(TableStoreContext);
1968 if (!store) {
1969 throw new Error("useTableStore must be used within TableStoreProvider");
1970 }
1971 return store(selector);
1972 }
1973
1974 /**
1975 * Raw store handle for imperative snapshots (native editor preview mode
1976 * pushes a cells/rows snapshot of the block tree into the store so the
1977 * existing preview UI can render from it).
1978 */
1979 export function useTableStoreApi(): TableStore {
1980 const store = useContext(TableStoreContext);
1981 if (!store) {
1982 throw new Error(
1983 "useTableStoreApi must be used within TableStoreProvider"
1984 );
1985 }
1986 return store;
1987 }
1988