PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.2.0
Tableberg – Simple Gutenberg Table Block v1.2.0
1.2.0 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 All 43 releases
tableberg / src / store / index.tsx

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

1,990 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 === clientIds.length &&
1011 state.nativeSelectedCells.every(
1012 (id, index) => id === clientIds[index]
1013 )
1014 ) {
1015 return state;
1016 }
1017
1018 return { nativeSelectedCells: clientIds };
1019 }),
1020
1021 setSelectedElement: (cell, elementIndex) => {
1022 set(state => {
1023 if (
1024 state.selectedElement?.cell === cell &&
1025 state.selectedElement.elementIndex === elementIndex
1026 ) {
1027 return state;
1028 }
1029
1030 return { selectedElement: { cell, elementIndex } };
1031 });
1032 },
1033
1034 clearSelectedElement: () => {
1035 set(state => {
1036 if (state.selectedElement === null) {
1037 return state;
1038 }
1039
1040 return { selectedElement: null };
1041 });
1042 },
1043
1044 isElementSelected: (cell, elementIndex) => {
1045 const { selectedElement } = get();
1046 return (
1047 selectedElement !== null &&
1048 selectedElement.cell === cell &&
1049 selectedElement.elementIndex === elementIndex
1050 );
1051 },
1052
1053 setSelectedRibbon: cell => {
1054 set(state => {
1055 if (
1056 state.selectedRibbonCell === cell &&
1057 state.selectedElement === null
1058 ) {
1059 return state;
1060 }
1061
1062 return { selectedRibbonCell: cell, selectedElement: null };
1063 });
1064 },
1065
1066 clearSelectedRibbon: () => {
1067 set(state => {
1068 if (state.selectedRibbonCell === null) {
1069 return state;
1070 }
1071
1072 return { selectedRibbonCell: null };
1073 });
1074 },
1075
1076 isRibbonSelected: cell => {
1077 const { selectedRibbonCell } = get();
1078 return (
1079 selectedRibbonCell !== null && selectedRibbonCell === cell
1080 );
1081 },
1082
1083 setTableEditPreview: tableEditPreview => {
1084 set(state => {
1085 const current = state.tableEditPreview;
1086
1087 if (
1088 current?.operation === tableEditPreview?.operation &&
1089 current?.target === tableEditPreview?.target &&
1090 current?.index === tableEditPreview?.index
1091 ) {
1092 return state;
1093 }
1094
1095 if (current === null && tableEditPreview === null) {
1096 return state;
1097 }
1098
1099 return { tableEditPreview };
1100 });
1101 },
1102
1103 clearTableEditPreview: () => {
1104 set(state => {
1105 if (state.tableEditPreview === null) {
1106 return state;
1107 }
1108
1109 return { tableEditPreview: null };
1110 });
1111 },
1112
1113 addSelectedCells: coords =>
1114 set(state => {
1115 // Toggle each coord: add if absent, remove if already
1116 // selected. Keeps the selection free of duplicates so
1117 // building a multi-cell selection for merging is reliable.
1118 const next = [...state.selectedCells];
1119
1120 for (const coord of coords) {
1121 const existingIndex = next.indexOf(coord);
1122
1123 if (existingIndex === -1) {
1124 next.push(coord);
1125 } else {
1126 next.splice(existingIndex, 1);
1127 }
1128 }
1129
1130 return { selectedCells: next };
1131 }),
1132
1133 setColumnSortable: (column, sortable) => {
1134 set(state => {
1135 // columns is an ARRAY — spreading it into an object
1136 // turned it into an indexed map, which then crashed
1137 // every columns.some() consumer.
1138 const columns = state.columns.slice();
1139 if (sortable === undefined) {
1140 if (columns[column]) {
1141 const { sortable: _, ...rest } = columns[column]!;
1142 columns[column] =
1143 Object.keys(rest).length === 0 ? null : rest;
1144 }
1145 } else {
1146 columns[column] = { ...columns[column], sortable };
1147 }
1148 return { columns };
1149 });
1150 },
1151
1152 setColumnWidth: (column, width) => {
1153 set(state => {
1154 const columns = state.columns.slice();
1155 const normalizedWidth = width?.trim();
1156
1157 if (!normalizedWidth) {
1158 if (columns[column]) {
1159 const { width: _, ...rest } = columns[column];
1160 if (Object.keys(rest).length === 0) {
1161 delete columns[column];
1162 } else {
1163 columns[column] = rest;
1164 }
1165 }
1166 } else {
1167 columns[column] = {
1168 ...columns[column],
1169 width: normalizedWidth,
1170 };
1171 }
1172
1173 return { columns };
1174 });
1175 },
1176
1177 insertRow: rowIndex => {
1178 set(state => {
1179 const insertAt = Math.max(
1180 0,
1181 Math.min(rowIndex, state.table.rows)
1182 );
1183 const rowTypeColors = getHomogeneousRowTypeColors({
1184 table: state.table,
1185 cells: state.cells,
1186 cellDefaults: state.cellDefaults,
1187 });
1188 const textElement = createElement("text") ?? undefined;
1189 const nextState = insertRowAt(
1190 state.table,
1191 state.rows,
1192 state.columns,
1193 state.cells,
1194 insertAt,
1195 textElement
1196 );
1197 const rowsToRecolor = getRowsToRecolorAfterInsert(
1198 insertAt,
1199 state.table.rows,
1200 state.table,
1201 rowTypeColors
1202 );
1203 const recoloredCells = applyRowTypeColorsToCells(
1204 nextState.cells,
1205 getRowSiblingCellCoordsFromCells(
1206 nextState.cells,
1207 rowsToRecolor
1208 ),
1209 nextState.table.rows,
1210 state.table,
1211 rowTypeColors
1212 );
1213
1214 const selectedCol =
1215 state.selectedCells.length > 0
1216 ? getCellColumn(state.selectedCells[0])
1217 : 0;
1218 const nextSelectedCol = Math.max(
1219 0,
1220 Math.min(selectedCol, nextState.table.cols - 1)
1221 );
1222
1223 return {
1224 table: nextState.table,
1225 rows: nextState.rows,
1226 columns: nextState.columns,
1227 cells: recoloredCells,
1228 selectedCells: [getCellKey(insertAt, nextSelectedCol)],
1229 selectedElement: null,
1230 selectedRibbonCell: null,
1231 };
1232 });
1233 },
1234
1235 deleteRow: rowIndex => {
1236 set(state => {
1237 if (state.table.rows <= 1) {
1238 return state;
1239 }
1240
1241 const deleteAt = Math.max(
1242 0,
1243 Math.min(rowIndex, state.table.rows - 1)
1244 );
1245 const nextState = deleteRowAt(
1246 state.table,
1247 state.rows,
1248 state.columns,
1249 state.cells,
1250 deleteAt
1251 );
1252
1253 const selectedCol =
1254 state.selectedCells.length > 0
1255 ? getCellColumn(state.selectedCells[0])
1256 : 0;
1257 const nextSelectedCol = Math.max(
1258 0,
1259 Math.min(selectedCol, nextState.table.cols - 1)
1260 );
1261 const nextSelectedRow = Math.max(
1262 0,
1263 Math.min(deleteAt, nextState.table.rows - 1)
1264 );
1265
1266 return {
1267 table: nextState.table,
1268 rows: nextState.rows,
1269 columns: nextState.columns,
1270 cells: nextState.cells,
1271 bindings: pruneUnusedBindings(
1272 state.bindings,
1273 nextState.cells
1274 ),
1275 selectedCells: [
1276 getCellKey(nextSelectedRow, nextSelectedCol),
1277 ],
1278 selectedElement: null,
1279 selectedRibbonCell: null,
1280 };
1281 });
1282 },
1283
1284 insertColumn: columnIndex => {
1285 set(state => {
1286 const insertAt = Math.max(
1287 0,
1288 Math.min(columnIndex, state.table.cols)
1289 );
1290 const rowTypeColors = getHomogeneousRowTypeColors({
1291 table: state.table,
1292 cells: state.cells,
1293 cellDefaults: state.cellDefaults,
1294 });
1295 const textElement = createElement("text") ?? undefined;
1296 const nextState = insertColumnAt(
1297 state.table,
1298 state.rows,
1299 state.columns,
1300 state.cells,
1301 insertAt,
1302 textElement
1303 );
1304 const insertedColumnCells = Object.keys(
1305 nextState.cells
1306 ).filter(
1307 key => getCellColumn(key as CellKey) === insertAt
1308 ) as CellKey[];
1309 const recoloredCells = applyRowTypeColorsToCells(
1310 nextState.cells,
1311 insertedColumnCells,
1312 nextState.table.rows,
1313 state.table,
1314 rowTypeColors
1315 );
1316
1317 const selectedRow =
1318 state.selectedCells.length > 0
1319 ? getCellRow(state.selectedCells[0])
1320 : 0;
1321 const nextSelectedRow = Math.max(
1322 0,
1323 Math.min(selectedRow, nextState.table.rows - 1)
1324 );
1325
1326 return {
1327 table: nextState.table,
1328 rows: nextState.rows,
1329 columns: nextState.columns,
1330 cells: recoloredCells,
1331 selectedCells: [getCellKey(nextSelectedRow, insertAt)],
1332 selectedElement: null,
1333 selectedRibbonCell: null,
1334 };
1335 });
1336 },
1337
1338 deleteColumn: columnIndex => {
1339 set(state => {
1340 if (state.table.cols <= 1) {
1341 return state;
1342 }
1343
1344 const deleteAt = Math.max(
1345 0,
1346 Math.min(columnIndex, state.table.cols - 1)
1347 );
1348 const nextState = deleteColumnAt(
1349 state.table,
1350 state.rows,
1351 state.columns,
1352 state.cells,
1353 deleteAt
1354 );
1355
1356 const selectedRow =
1357 state.selectedCells.length > 0
1358 ? getCellRow(state.selectedCells[0])
1359 : 0;
1360 const nextSelectedRow = Math.max(
1361 0,
1362 Math.min(selectedRow, nextState.table.rows - 1)
1363 );
1364 const nextSelectedCol = Math.max(
1365 0,
1366 Math.min(deleteAt, nextState.table.cols - 1)
1367 );
1368
1369 return {
1370 table: nextState.table,
1371 rows: nextState.rows,
1372 columns: nextState.columns,
1373 cells: nextState.cells,
1374 bindings: pruneUnusedBindings(
1375 state.bindings,
1376 nextState.cells
1377 ),
1378 selectedCells: [
1379 getCellKey(nextSelectedRow, nextSelectedCol),
1380 ],
1381 selectedElement: null,
1382 selectedRibbonCell: null,
1383 };
1384 });
1385 },
1386
1387 duplicateRow: rowIndex => {
1388 set(state => {
1389 if (hasMergedCells(state.cells)) {
1390 return state;
1391 }
1392
1393 const sourceRow = Math.max(
1394 0,
1395 Math.min(rowIndex, state.table.rows - 1)
1396 );
1397
1398 const nextState = duplicateRowAt(
1399 state.table,
1400 state.rows,
1401 state.columns,
1402 state.cells,
1403 sourceRow
1404 );
1405
1406 const selectedCol =
1407 state.selectedCells.length > 0
1408 ? getCellColumn(state.selectedCells[0])
1409 : 0;
1410 const nextSelectedCol = Math.max(
1411 0,
1412 Math.min(selectedCol, nextState.table.cols - 1)
1413 );
1414
1415 return {
1416 table: nextState.table,
1417 rows: nextState.rows,
1418 columns: nextState.columns,
1419 cells: nextState.cells,
1420 selectedCells: [
1421 getCellKey(sourceRow + 1, nextSelectedCol),
1422 ],
1423 selectedElement: null,
1424 selectedRibbonCell: null,
1425 };
1426 });
1427 },
1428
1429 duplicateColumn: columnIndex => {
1430 set(state => {
1431 if (hasMergedCells(state.cells)) {
1432 return state;
1433 }
1434
1435 const sourceColumn = Math.max(
1436 0,
1437 Math.min(columnIndex, state.table.cols - 1)
1438 );
1439
1440 const nextState = duplicateColumnAt(
1441 state.table,
1442 state.rows,
1443 state.columns,
1444 state.cells,
1445 sourceColumn
1446 );
1447
1448 const selectedRow =
1449 state.selectedCells.length > 0
1450 ? getCellRow(state.selectedCells[0])
1451 : 0;
1452 const nextSelectedRow = Math.max(
1453 0,
1454 Math.min(selectedRow, nextState.table.rows - 1)
1455 );
1456
1457 return {
1458 table: nextState.table,
1459 rows: nextState.rows,
1460 columns: nextState.columns,
1461 cells: nextState.cells,
1462 selectedCells: [
1463 getCellKey(nextSelectedRow, sourceColumn + 1),
1464 ],
1465 selectedElement: null,
1466 selectedRibbonCell: null,
1467 };
1468 });
1469 },
1470
1471 moveRow: (subjectRow, targetRow) => {
1472 set(state => {
1473 if (hasMergedCells(state.cells)) {
1474 return state;
1475 }
1476
1477 if (
1478 subjectRow < 0 ||
1479 subjectRow >= state.table.rows ||
1480 targetRow < 0 ||
1481 targetRow >= state.table.rows
1482 ) {
1483 return state;
1484 }
1485
1486 const nextState = moveRowTo(
1487 state.table,
1488 state.rows,
1489 state.columns,
1490 state.cells,
1491 subjectRow,
1492 targetRow
1493 );
1494
1495 const selectedCol =
1496 state.selectedCells.length > 0
1497 ? getCellColumn(state.selectedCells[0])
1498 : 0;
1499 const nextSelectedCol = Math.max(
1500 0,
1501 Math.min(selectedCol, nextState.table.cols - 1)
1502 );
1503
1504 return {
1505 table: nextState.table,
1506 rows: nextState.rows,
1507 columns: nextState.columns,
1508 cells: nextState.cells,
1509 selectedCells: [getCellKey(targetRow, nextSelectedCol)],
1510 selectedElement: null,
1511 selectedRibbonCell: null,
1512 };
1513 });
1514 },
1515
1516 moveColumn: (subjectColumn, targetColumn) => {
1517 set(state => {
1518 if (hasMergedCells(state.cells)) {
1519 return state;
1520 }
1521
1522 if (
1523 subjectColumn < 0 ||
1524 subjectColumn >= state.table.cols ||
1525 targetColumn < 0 ||
1526 targetColumn >= state.table.cols
1527 ) {
1528 return state;
1529 }
1530
1531 const nextState = moveColumnTo(
1532 state.table,
1533 state.rows,
1534 state.columns,
1535 state.cells,
1536 subjectColumn,
1537 targetColumn
1538 );
1539
1540 const selectedRow =
1541 state.selectedCells.length > 0
1542 ? getCellRow(state.selectedCells[0])
1543 : 0;
1544 const nextSelectedRow = Math.max(
1545 0,
1546 Math.min(selectedRow, nextState.table.rows - 1)
1547 );
1548
1549 return {
1550 table: nextState.table,
1551 rows: nextState.rows,
1552 columns: nextState.columns,
1553 cells: nextState.cells,
1554 selectedCells: [
1555 getCellKey(nextSelectedRow, targetColumn),
1556 ],
1557 selectedElement: null,
1558 selectedRibbonCell: null,
1559 };
1560 });
1561 },
1562
1563 getColumnConfig: column => {
1564 return get().columns[column] || undefined;
1565 },
1566
1567 setRowHeight: (row, height) => {
1568 set(state => {
1569 const rowConfigs = state.rows.slice();
1570 const normalizedHeight = height?.trim();
1571
1572 if (!normalizedHeight) {
1573 if (rowConfigs[row]) {
1574 const { height: _, ...rest } = rowConfigs[row];
1575 if (Object.keys(rest).length === 0) {
1576 delete rowConfigs[row];
1577 } else {
1578 rowConfigs[row] = rest;
1579 }
1580 }
1581 } else {
1582 rowConfigs[row] = {
1583 ...rowConfigs[row],
1584 height: normalizedHeight,
1585 };
1586 }
1587
1588 return { rows: rowConfigs };
1589 });
1590 },
1591
1592 getRowConfig: row => {
1593 return get().rows[row] || undefined;
1594 },
1595
1596 isColumnSortableAllowed: column => {
1597 return isColumnSortable(column, get().cells);
1598 },
1599
1600 enterSortPreviewMode: () => {
1601 set({
1602 sortPreviewMode: true,
1603 previewSortColumn: null,
1604 previewSortOrder: "asc",
1605 selectedCells: [],
1606 });
1607 },
1608
1609 exitSortPreviewMode: () => {
1610 set({
1611 sortPreviewMode: false,
1612 previewSortColumn: null,
1613 previewSortOrder: "asc",
1614 });
1615 },
1616
1617 setPreviewSort: (column, order) => {
1618 set(state => ({
1619 previewSortColumn: column,
1620 previewSortOrder: order || state.previewSortOrder,
1621 }));
1622 },
1623
1624 togglePreviewSort: column => {
1625 set(state => {
1626 if (state.previewSortColumn === column) {
1627 if (state.previewSortOrder === "asc") {
1628 return { previewSortOrder: "desc" as SortOrder };
1629 } else {
1630 return {
1631 previewSortColumn: null,
1632 previewSortOrder: "asc" as SortOrder,
1633 };
1634 }
1635 }
1636 return {
1637 previewSortColumn: column,
1638 previewSortOrder: "asc" as SortOrder,
1639 };
1640 });
1641 },
1642
1643 getSortedRowIndices: () => {
1644 const {
1645 sortPreviewMode,
1646 previewSortColumn,
1647 previewSortOrder,
1648 table,
1649 columns,
1650 cells,
1651 } = get();
1652
1653 if (!sortPreviewMode || previewSortColumn === null) {
1654 return Array.from({ length: table.rows }, (_, i) => i);
1655 }
1656
1657 const sortType = columns[previewSortColumn]?.sortable || "text";
1658
1659 return sortRowsByColumn(
1660 cells,
1661 table.rows,
1662 table,
1663 previewSortColumn,
1664 sortType,
1665 previewSortOrder
1666 );
1667 },
1668
1669 setCurrentPage: page => {
1670 set({ currentPage: page });
1671 },
1672
1673 setPaginationConfig: config => {
1674 set(state => {
1675 const newPagination = {
1676 ...state.table.pagination!,
1677 ...config,
1678 };
1679
1680 const totalPages = getTotalPages(
1681 state.table.rows,
1682 newPagination.pageSize,
1683 state.table.headerEnabled,
1684 state.table.footerEnabled
1685 );
1686 const maxPage = Math.max(0, totalPages - 1);
1687 const currentPage = Math.min(state.currentPage, maxPage);
1688
1689 return {
1690 table: {
1691 ...state.table,
1692 pagination: newPagination,
1693 },
1694 currentPage,
1695 };
1696 });
1697 },
1698
1699 isPaginationAllowed: () => {
1700 return !tableHasRowSpanningCells(get().cells);
1701 },
1702
1703 setSearchTerm: term => {
1704 set(_ => {
1705 return {
1706 searchTerm: term,
1707 currentPage: 0,
1708 };
1709 });
1710 },
1711
1712 setShowCaption: show => {
1713 set(state => {
1714 if (state.showCaption === show) {
1715 return state;
1716 }
1717
1718 return { showCaption: show };
1719 });
1720 },
1721
1722 toggleRowColumnControls: () => {
1723 set(state => ({
1724 showRowColumnControls: !state.showRowColumnControls,
1725 }));
1726 },
1727
1728 toggleDuplicateMoveControls: () => {
1729 set(state => ({
1730 showDuplicateMoveControls: !state.showDuplicateMoveControls,
1731 }));
1732 },
1733
1734 getFilteredRowIndices: () => {
1735 const { searchTerm, table, cells } = get();
1736
1737 if (!table.search?.enabled || !searchTerm.trim()) {
1738 return Array.from({ length: table.rows }, (_, i) => i);
1739 }
1740
1741 return filterRowsBySearch(
1742 cells,
1743 table.rows,
1744 table.cols,
1745 table,
1746 searchTerm
1747 );
1748 },
1749
1750 setTableAttrs: attrs => set({ ...attrs }),
1751
1752 getTableAttrs: () => {
1753 const {
1754 version,
1755 isExample,
1756 table,
1757 rows,
1758 columns,
1759 cells,
1760 bindings,
1761 cellDefaults,
1762 } = get();
1763 return {
1764 version,
1765 isExample,
1766 table,
1767 rows,
1768 columns,
1769 cells,
1770 bindings,
1771 cellDefaults,
1772 };
1773 },
1774
1775 setAttrVersion: version =>
1776 set(state => {
1777 if (state.version === version) {
1778 return state;
1779 }
1780
1781 return { version };
1782 }),
1783
1784 reset: () => set(initialState),
1785 };
1786 });
1787 }
1788
1789 // Exported so the native editor's element bridge can mount a patched store
1790 // around reused element components (see src/native/element-bridge.tsx).
1791 export const TableStoreContext = createContext<TableStore | null>(null);
1792
1793 interface TableStoreProviderProps {
1794 children: ReactNode;
1795 clientId: string;
1796 attributes: TablebergBlockAttrs;
1797 setAttributes: (attrs: Partial<TablebergBlockAttrs>) => void;
1798 /**
1799 * Native-blocks mode: cell/row content lives in the block tree, not in
1800 * attrs. The store only mirrors table-level state (for the inspector
1801 * controls), so cells/rows must not sync back into the block attributes.
1802 */
1803 contentSyncDisabled?: boolean;
1804 }
1805
1806 // Stable fallbacks: fresh objects on every attributes-effect run would keep
1807 // changing state references and ping-pong with setAttributes into an
1808 // infinite update loop (React #185).
1809 const EMPTY_CELLS: TablebergBlockAttrs["cells"] = {};
1810 const EMPTY_ROWS: TablebergBlockAttrs["rows"] = [];
1811 const EMPTY_BINDINGS: TablebergBlockAttrs["bindings"] = {};
1812
1813 export function TableStoreProvider({
1814 children,
1815 attributes,
1816 setAttributes,
1817 contentSyncDisabled = false,
1818 }: TableStoreProviderProps) {
1819 const storeRef = useRef<TableStore | null>(null);
1820
1821 // Posts saved while the setColumnSortable object-spread bug was live have
1822 // columns as an indexed map instead of an array — normalize on the way in.
1823 const normalizeColumns = (
1824 columns: TablebergBlockAttrs["columns"] | undefined
1825 ): TablebergBlockAttrs["columns"] => {
1826 if (Array.isArray(columns)) {
1827 return columns;
1828 }
1829 if (!columns || typeof columns !== "object") {
1830 return [];
1831 }
1832 const result: TablebergBlockAttrs["columns"] = [];
1833 for (const [key, value] of Object.entries(columns)) {
1834 const index = Number(key);
1835 if (Number.isInteger(index) && index >= 0) {
1836 result[index] = value;
1837 }
1838 }
1839 return result;
1840 };
1841
1842 // v4 attrs carry no cells/rows (and may omit defaulted keys); the store
1843 // still expects the collections to exist.
1844 // Tables created with partial config (or by older builds) must still
1845 // satisfy controls that destructure pagination/search/responsive — but
1846 // only build a new object when keys are actually missing, so the
1847 // reference stays stable once the table is complete.
1848 const withTableDefaults = (
1849 table: TablebergBlockAttrs["table"] | undefined
1850 ): TablebergBlockAttrs["table"] => {
1851 if (!table) {
1852 return attrDefaults.table;
1853 }
1854 for (const key of Object.keys(attrDefaults.table)) {
1855 if (!(key in table)) {
1856 return { ...attrDefaults.table, ...table };
1857 }
1858 }
1859 return table;
1860 };
1861
1862 // Row/column counts are derived from the block tree, which the preview
1863 // snapshot keeps fresh; the counts in the attributes can be stale, so
1864 // never clobber the live ones. Returns the same reference when the
1865 // values already agree, so this converges instead of looping.
1866 const withLiveCounts = (
1867 table: TablebergBlockAttrs["table"],
1868 currentTable: TablebergBlockAttrs["table"] | undefined
1869 ): TablebergBlockAttrs["table"] => {
1870 if (
1871 !currentTable ||
1872 (table.rows === currentTable.rows &&
1873 table.cols === currentTable.cols)
1874 ) {
1875 return table;
1876 }
1877 return { ...table, rows: currentTable.rows, cols: currentTable.cols };
1878 };
1879
1880 // The store's initial state needs every key present.
1881 const initialAttrs = (attrs: TablebergBlockAttrs) =>
1882 contentSyncDisabled
1883 ? {
1884 ...attrs,
1885 table: withTableDefaults(attrs.table),
1886 cells: attrs.cells ?? EMPTY_CELLS,
1887 rows: attrs.rows ?? EMPTY_ROWS,
1888 columns: normalizeColumns(attrs.columns),
1889 bindings: attrs.bindings ?? EMPTY_BINDINGS,
1890 }
1891 : attrs;
1892
1893 // Applied on every attributes change. In native mode the cell content and
1894 // row configs live in the block tree (the preview snapshot writes them
1895 // into the store), so those keys must be left out entirely — writing them
1896 // from the attributes would wipe the snapshot, because effects run
1897 // child-first and this provider is the parent.
1898 const syncAttrs = (
1899 attrs: TablebergBlockAttrs,
1900 current: TableState
1901 ): Partial<TableState> => {
1902 if (!contentSyncDisabled) {
1903 return attrs;
1904 }
1905
1906 const { cells: _cells, rows: _rows, ...rest } = attrs;
1907
1908 return {
1909 ...rest,
1910 table: withLiveCounts(
1911 withTableDefaults(attrs.table),
1912 current.table
1913 ),
1914 columns: normalizeColumns(attrs.columns),
1915 bindings: attrs.bindings ?? EMPTY_BINDINGS,
1916 };
1917 };
1918
1919 if (!storeRef.current) {
1920 storeRef.current = createTableStore(initialAttrs(attributes));
1921 }
1922
1923 useEffect(() => {
1924 if (storeRef.current) {
1925 storeRef.current.setState(
1926 syncAttrs(attributes, storeRef.current.getState())
1927 );
1928 }
1929 }, [attributes]);
1930
1931 useEffect(() => {
1932 if (storeRef.current) {
1933 const unsubscribe = storeRef.current.subscribe(state => {
1934 if (contentSyncDisabled) {
1935 setAttributes({
1936 version: state.version,
1937 isExample: state.isExample,
1938 table: state.table,
1939 columns: state.columns,
1940 bindings: state.bindings,
1941 cellDefaults: state.cellDefaults,
1942 });
1943 return;
1944 }
1945
1946 setAttributes({
1947 version: state.version,
1948 isExample: state.isExample,
1949 table: state.table,
1950 rows: state.rows,
1951 columns: state.columns,
1952 cells: state.cells,
1953 bindings: state.bindings,
1954 cellDefaults: state.cellDefaults,
1955 });
1956 });
1957 return unsubscribe;
1958 }
1959 }, [setAttributes, contentSyncDisabled]);
1960
1961 return (
1962 <TableStoreContext.Provider value={storeRef.current}>
1963 {children}
1964 </TableStoreContext.Provider>
1965 );
1966 }
1967
1968 export function useTableStore<T>(selector: (state: TableState) => T): T {
1969 const store = useContext(TableStoreContext);
1970 if (!store) {
1971 throw new Error("useTableStore must be used within TableStoreProvider");
1972 }
1973 return store(selector);
1974 }
1975
1976 /**
1977 * Raw store handle for imperative snapshots (native editor preview mode
1978 * pushes a cells/rows snapshot of the block tree into the store so the
1979 * existing preview UI can render from it).
1980 */
1981 export function useTableStoreApi(): TableStore {
1982 const store = useContext(TableStoreContext);
1983 if (!store) {
1984 throw new Error(
1985 "useTableStoreApi must be used within TableStoreProvider"
1986 );
1987 }
1988 return store;
1989 }
1990