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 / blocks / table / index.tsx

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

690 lines 24.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { CSSProperties, useEffect, useState } from "react";
2 import {
3 InnerBlocks,
4 store as blockEditorStore,
5 useBlockProps,
6 useInnerBlocksProps,
7 } from "@wordpress/block-editor";
8 import {
9 BlockConfiguration as BlockConfig,
10 BlockEditProps,
11 registerBlockType,
12 } from "@wordpress/blocks";
13 import {
14 dispatch as dataDispatch,
15 select as dataSelect,
16 useDispatch,
17 useSelect,
18 } from "@wordpress/data";
19 import { Button, Placeholder, TextControl } from "@wordpress/components";
20 import { __ } from "@wordpress/i18n";
21 import blockIcon from "@tableberg/shared/icons/tableberg";
22
23 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
24
25 import metadata from "./block.json";
26 import exampleImage from "../../example.png";
27 import transforms from "../../transforms";
28 import TablebergControls from "./controls";
29 import { TableCaption } from "../../components/TableCaption";
30 import { SearchInput } from "../../components/SearchInput";
31 import { PaginationNavigation } from "../../components/PaginationNavigation";
32 import { getPagedRowIndices } from "../../pagination";
33 import { PrimaryTable } from "../../preview/table";
34 import {
35 TableStoreProvider,
36 useTableStore,
37 useTableStoreApi,
38 } from "../../store";
39 import {
40 attrDefaults,
41 TablebergBlockAttrs as BlockAttrs,
42 } from "../../attributes";
43 import { blocksToV3Content } from "../../migrate/blocks-to-attrs";
44 import {
45 buildRowBlocksFromV3,
46 isV3OrOlder,
47 toV4Attrs,
48 } from "../../migrate/v3-to-v4";
49 import { buildOccupancy, GridRow } from "./grid-model";
50
51 const queryClient = new QueryClient({
52 defaultOptions: {
53 queries: {
54 gcTime: 24 * 60 * 60 * 1000,
55 staleTime: Infinity,
56 refetchOnWindowFocus: false,
57 },
58 },
59 });
60
61 const zeroCssValuePattern = /^0(?:\.0+)?(?:[a-z%]+)?$/i;
62
63 function hasNonZeroCssValue(value?: string) {
64 const trimmed = value?.trim() || "";
65 return !!trimmed && !zeroCssValuePattern.test(trimmed);
66 }
67
68 /**
69 * Native-blocks table editor (Phase 2 MVP). The table is a dynamic block;
70 * rows/cells/elements are real InnerBlocks. Table-level inspector controls
71 * reach parity in Phase 4.
72 */
73
74 function buildEmptyTableAttrs(rows: number, cols: number): Partial<BlockAttrs> {
75 const cells: Record<string, { elements: unknown[] }> = {};
76 for (let r = 0; r < rows; r++) {
77 for (let c = 0; c < cols; c++) {
78 cells[`${r},${c}`] = {
79 elements: [
80 {
81 name: "text",
82 attributes: {},
83 },
84 ],
85 };
86 }
87 }
88
89 return {
90 version: 3,
91 // Full defaults, like the core/table transform does — controls
92 // destructure pagination/search/responsive and crash on a bare table.
93 table: {
94 ...attrDefaults.table,
95 rows,
96 cols,
97 headerEnabled: false,
98 footerEnabled: false,
99 },
100 cells,
101 } as unknown as Partial<BlockAttrs>;
102 }
103
104 function NativeTableCreator({
105 onCreate,
106 }: {
107 onCreate: (rows: number, cols: number) => void;
108 }) {
109 const [rows, setRows] = useState("4");
110 const [cols, setCols] = useState("4");
111
112 return (
113 <Placeholder
114 icon={blockIcon}
115 label={__("Tableberg Table (native)", "tableberg")}
116 instructions={__(
117 "Choose the table size to get started.",
118 "tableberg"
119 )}
120 >
121 <div
122 style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}
123 >
124 <TextControl
125 label={__("Rows", "tableberg")}
126 type="number"
127 min={1}
128 value={rows}
129 onChange={setRows}
130 />
131 <TextControl
132 label={__("Columns", "tableberg")}
133 type="number"
134 min={1}
135 value={cols}
136 onChange={setCols}
137 />
138 <Button
139 variant="primary"
140 onClick={() => {
141 const r = Math.max(1, parseInt(rows, 10) || 0);
142 const c = Math.max(1, parseInt(cols, 10) || 0);
143 onCreate(r, c);
144 }}
145 >
146 {__("Create Table", "tableberg")}
147 </Button>
148 </div>
149 </Placeholder>
150 );
151 }
152
153 function NativeTableEdit(
154 props: BlockEditProps<BlockAttrs> & Record<string, unknown>
155 ) {
156 const { attributes, setAttributes, clientId } = props;
157 const { replaceInnerBlocks } = useDispatch(blockEditorStore) as any;
158
159 // Anything the pro plugin injected through `editor.BlockEdit` (e.g. a
160 // future ProSortingControl) is passed on to the sidebar. Mirrors the
161 // element bridge's forwarding for cell elements.
162 const proProps = Object.fromEntries(
163 Object.entries(props).filter(([key]) => key.startsWith("Pro"))
164 );
165 const proExtensionActive = Boolean(props.ProStickyHeaderControl);
166
167 const hasRowBlocks = useSelect(
168 select => (select(blockEditorStore) as any).getBlockCount(clientId) > 0,
169 [clientId]
170 );
171
172 const needsConversion =
173 isV3OrOlder(attributes) && (attributes.table?.rows ?? 0) > 0;
174
175 // Safety net: any v3 content that reaches the editor unconverted (REST
176 // filter missed it, direct paste, the creator below) is materialized into
177 // real blocks here. The primary path is the PHP REST migration.
178 useEffect(() => {
179 if (!needsConversion) {
180 return;
181 }
182
183 const rowBlocks = buildRowBlocksFromV3(attributes);
184 setAttributes(toV4Attrs(attributes) as Partial<BlockAttrs>);
185 replaceInnerBlocks(clientId, rowBlocks, false);
186 }, [needsConversion]);
187
188 const tableConfig = attributes.table;
189 const tableWidth = (tableConfig?.tableWidth || "auto").trim();
190 const innerBorderType = proExtensionActive
191 ? tableConfig?.innerBorderType || ""
192 : "";
193 const innerBorderCss = useSelect(
194 select => {
195 if (innerBorderType !== "row" && innerBorderType !== "col") {
196 return "";
197 }
198
199 const be = select(blockEditorStore) as any;
200 const rows: GridRow[] = (be.getBlock(clientId)?.innerBlocks ?? [])
201 .filter((block: any) => block.name === "tableberg/row")
202 .map((rowBlock: any) =>
203 rowBlock.innerBlocks
204 .filter((block: any) => block.name === "tableberg/cell")
205 .map((cellBlock: any) => ({
206 id: cellBlock.clientId,
207 rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1,
208 colSpan: cellBlock.attributes?.span?.colSpan ?? 1,
209 }))
210 );
211 const occupancy = buildOccupancy(rows);
212 const excluded: Record<
213 "top" | "right" | "bottom" | "left",
214 string[]
215 > = {
216 top: [],
217 right: [],
218 bottom: [],
219 left: [],
220 };
221
222 rows.forEach(row => {
223 row.forEach(cell => {
224 const position = occupancy.anchors.get(cell.id);
225 if (!position) {
226 return;
227 }
228 const selector = `#block-${cell.id}`;
229
230 if (innerBorderType === "row") {
231 excluded.left.push(selector);
232 excluded.right.push(selector);
233 if (position.row === 0) {
234 excluded.top.push(selector);
235 }
236 if (position.row + cell.rowSpan >= rows.length) {
237 excluded.bottom.push(selector);
238 }
239 } else {
240 excluded.top.push(selector);
241 excluded.bottom.push(selector);
242 if (position.col === 0) {
243 excluded.left.push(selector);
244 }
245 if (position.col + cell.colSpan >= occupancy.cols) {
246 excluded.right.push(selector);
247 }
248 }
249 });
250 });
251
252 return (Object.keys(excluded) as Array<keyof typeof excluded>)
253 .filter(side => excluded[side].length > 0)
254 .map(
255 side =>
256 `${excluded[side].join(",")} { border-${side}: none !important; }`
257 )
258 .join("\n");
259 },
260 [clientId, innerBorderType]
261 );
262
263 // ----- Preview overlay -----------------------------------------------
264 // Sorting/search/responsive previews render through the existing
265 // (store-driven) PrimaryTable as a read-only overlay. A snapshot of the
266 // block tree is pushed into the store when a preview activates.
267 const storeApi = useTableStoreApi();
268
269 // Row/column counts come from the block tree. The store keeps "live"
270 // counts over the ones in the attributes (see withLiveCounts), so without
271 // this they only refreshed during a preview, and a newly created table
272 // kept 0/0: no "Equal width columns" toggle and no column widths.
273 const liveCounts = useSelect(
274 select => {
275 const be = select(blockEditorStore) as any;
276 const rows: GridRow[] = (be.getBlock(clientId)?.innerBlocks ?? [])
277 .filter((block: any) => block.name === "tableberg/row")
278 .map((rowBlock: any) =>
279 rowBlock.innerBlocks
280 .filter((block: any) => block.name === "tableberg/cell")
281 .map((cellBlock: any) => ({
282 id: cellBlock.clientId,
283 rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1,
284 colSpan: cellBlock.attributes?.span?.colSpan ?? 1,
285 }))
286 );
287 // A string keeps the selector's result stable between renders.
288 return `${rows.length},${rows.length ? buildOccupancy(rows).cols : 0}`;
289 },
290 [clientId]
291 );
292
293 useEffect(() => {
294 const [rows, cols] = liveCounts.split(",").map(Number);
295 const table = storeApi.getState().table;
296 if (rows > 0 && (table.rows !== rows || table.cols !== cols)) {
297 storeApi.setState({ table: { ...table, rows, cols } });
298 }
299 }, [liveCounts, storeApi]);
300
301 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
302 const searchTerm = useTableStore(state => state.searchTerm);
303 const setSearchTerm = useTableStore(state => state.setSearchTerm);
304
305 // The search config toggle now lives in pro (attributes-only, no store
306 // access), so free clears a stale live search term here instead of in
307 // setSearchConfig when the pro control disables search mid-preview.
308 const searchEnabled =
309 proExtensionActive && !!attributes.table?.search?.enabled;
310 useEffect(() => {
311 if (!searchEnabled && searchTerm) {
312 setSearchTerm("");
313 }
314 }, [searchEnabled]);
315
316 const responsivePreviewDevice = useSelect(select => {
317 const rawDeviceType =
318 (select("core/editor") as any)?.getDeviceType?.() ||
319 (
320 select("core/edit-site") as any
321 )?.__experimentalGetPreviewDeviceType?.() ||
322 (
323 select("core/edit-post") as any
324 )?.__experimentalGetPreviewDeviceType?.();
325
326 return (rawDeviceType || "Desktop").toLowerCase();
327 }, []);
328 const hasResponsivePreview =
329 responsivePreviewDevice !== "desktop" &&
330 !!(
331 tableConfig?.responsive as
332 | Record<string, { enabled?: boolean }>
333 | undefined
334 )?.[responsivePreviewDevice]?.enabled;
335
336 const previewActive =
337 !needsConversion &&
338 (sortPreviewMode || !!searchTerm.trim() || hasResponsivePreview);
339
340 // The overlay may only render AFTER the snapshot landed in the store —
341 // rendering PrimaryTable against an empty cells map crashes the block.
342 const [previewReady, setPreviewReady] = useState(false);
343 useEffect(() => {
344 if (!previewActive) {
345 setPreviewReady(false);
346 return;
347 }
348
349 const be = dataSelect(blockEditorStore) as any;
350 const rowBlocks = be.getBlock(clientId)?.innerBlocks ?? [];
351 const content = blocksToV3Content(rowBlocks);
352
353 storeApi.setState({
354 cells: content.cells,
355 rows: content.rows,
356 table: {
357 ...storeApi.getState().table,
358 rows: content.rowsCount,
359 cols: content.colsCount,
360 },
361 });
362 setPreviewReady(true);
363 }, [previewActive, clientId, storeApi]);
364
365 // ----------------------------------------------------------------------
366
367 const blockProps = useBlockProps({
368 className: `wp-block-tableberg tableberg-native-editor${
369 tableWidth === "wide"
370 ? " alignwide"
371 : tableWidth === "full"
372 ? " alignfull"
373 : ""
374 }${
375 proExtensionActive && tableConfig?.stickyHeader
376 ? " tableberg-native-sticky-header"
377 : ""
378 }`,
379 });
380
381 const innerBlocksProps = useInnerBlocksProps(
382 {},
383 {
384 allowedBlocks: ["tableberg/row"],
385 renderAppender: false,
386 }
387 );
388
389 // ----- Pagination on the editable canvas ------------------------------
390 // The navigation drives store.currentPage; rows outside the current page
391 // are hidden with CSS so the blocks stay mounted and editable.
392 const currentPage = useTableStore(state => state.currentPage);
393 const paginationConfig = tableConfig?.pagination;
394 const paginationWanted = proExtensionActive && !!paginationConfig?.enabled;
395
396 const paginationInfo = useSelect(
397 select => {
398 if (!paginationWanted) {
399 return { rowCount: 0, hasRowSpans: false };
400 }
401 const be = select(blockEditorStore) as any;
402 const rowBlocks = be.getBlock(clientId)?.innerBlocks ?? [];
403 let hasRowSpans = false;
404 for (const rowBlock of rowBlocks) {
405 for (const cellBlock of rowBlock.innerBlocks ?? []) {
406 if ((cellBlock.attributes?.span?.rowSpan ?? 1) > 1) {
407 hasRowSpans = true;
408 break;
409 }
410 }
411 if (hasRowSpans) {
412 break;
413 }
414 }
415 return { rowCount: rowBlocks.length, hasRowSpans };
416 },
417 [clientId, paginationWanted]
418 );
419
420 // Row-spanning cells break row paging — same guard as the frontend.
421 const paginationActive = paginationWanted && !paginationInfo.hasRowSpans;
422
423 let paginationCss = "";
424 let dataRowCount = 0;
425 if (paginationActive) {
426 const headerOn = !!tableConfig?.headerEnabled;
427 const footerOn = !!tableConfig?.footerEnabled;
428 dataRowCount =
429 paginationInfo.rowCount - (headerOn ? 1 : 0) - (footerOn ? 1 : 0);
430
431 const visible = new Set(
432 getPagedRowIndices(
433 paginationInfo.rowCount,
434 Math.max(1, paginationConfig?.pageSize ?? 10),
435 currentPage,
436 headerOn,
437 footerOn
438 )
439 );
440 const hidden: number[] = [];
441 for (let i = 0; i < paginationInfo.rowCount; i++) {
442 if (!visible.has(i)) {
443 hidden.push(i);
444 }
445 }
446 paginationCss = hidden
447 .map(
448 i =>
449 `#block-${clientId} table > tbody > tr:nth-child(${
450 i + 1
451 }) { display: none; }`
452 )
453 .join("\n");
454 }
455 // ----------------------------------------------------------------------
456
457 if (!hasRowBlocks && !needsConversion) {
458 return (
459 <figure {...blockProps}>
460 <NativeTableCreator
461 onCreate={(rows, cols) => {
462 const v3 = buildEmptyTableAttrs(rows, cols);
463 const rowBlocks = buildRowBlocksFromV3(v3);
464 setAttributes(toV4Attrs(v3) as Partial<BlockAttrs>);
465 replaceInnerBlocks(clientId, rowBlocks, false);
466 }}
467 />
468 </figure>
469 );
470 }
471
472 const horizontalCellSpacing =
473 tableConfig?.cellSpacing?.horizontal?.trim() || "0";
474 const verticalCellSpacing =
475 tableConfig?.cellSpacing?.vertical?.trim() || "0";
476 const hasCellSpacing =
477 (horizontalCellSpacing !== "0" && horizontalCellSpacing !== "") ||
478 (verticalCellSpacing !== "0" && verticalCellSpacing !== "");
479
480 const radius = attributes.cellDefaults?.styles?.borderRadius;
481 const hasTableRadius = !!(
482 hasNonZeroCssValue(radius?.topLeft) ||
483 hasNonZeroCssValue(radius?.topRight) ||
484 hasNonZeroCssValue(radius?.bottomRight) ||
485 hasNonZeroCssValue(radius?.bottomLeft)
486 );
487 const outerBorder = {
488 top: tableConfig?.tableBorder?.top,
489 right: tableConfig?.tableBorder?.right,
490 bottom: tableConfig?.tableBorder?.bottom,
491 left: tableConfig?.tableBorder?.left,
492 };
493
494 const tableStyle: CSSProperties = {
495 borderCollapse: hasCellSpacing ? "separate" : "collapse",
496 borderSpacing: hasCellSpacing
497 ? `${horizontalCellSpacing} ${verticalCellSpacing}`
498 : undefined,
499 width: "100%",
500 borderTop: hasTableRadius
501 ? undefined
502 : tableConfig?.tableBorder?.top || undefined,
503 borderRight: hasTableRadius
504 ? undefined
505 : tableConfig?.tableBorder?.right || undefined,
506 borderBottom: hasTableRadius
507 ? undefined
508 : tableConfig?.tableBorder?.bottom || undefined,
509 borderLeft: hasTableRadius
510 ? undefined
511 : tableConfig?.tableBorder?.left || undefined,
512 };
513
514 // Column widths, mirroring what TableRenderer puts on each cell: equal
515 // shares when "Equal width columns" is on, otherwise the per-column
516 // widths. Applied through a colgroup rather than per cell so the cells
517 // stay free of a table-wide subscription.
518 const columnCount = tableConfig?.cols ?? 0;
519 const columnWidths: (string | undefined)[] | null =
520 columnCount > 0
521 ? tableConfig?.fixedColumnWidths
522 ? Array.from(
523 { length: columnCount },
524 () => `${100 / columnCount}%`
525 )
526 : Array.from(
527 { length: columnCount },
528 (_, index) => attributes.columns?.[index]?.width
529 )
530 : null;
531
532 const spacing: CSSProperties = {};
533 const margin = tableConfig?.margin;
534 const padding = tableConfig?.padding;
535 if (margin?.top) spacing.marginTop = margin.top;
536 if (margin?.right) spacing.marginRight = margin.right;
537 if (margin?.bottom) spacing.marginBottom = margin.bottom;
538 if (margin?.left) spacing.marginLeft = margin.left;
539 if (padding?.top) spacing.paddingTop = padding.top;
540 if (padding?.right) spacing.paddingRight = padding.right;
541 if (padding?.bottom) spacing.paddingBottom = padding.bottom;
542 if (padding?.left) spacing.paddingLeft = padding.left;
543
544 // The rounded wrapper owns only the explicit table border. Cell borders
545 // stay on cells so they do not create a second, unintended outer border.
546 const radiusWrapperStyle: CSSProperties | undefined = hasTableRadius
547 ? {
548 borderTopLeftRadius: radius?.topLeft,
549 borderTopRightRadius: radius?.topRight,
550 borderBottomRightRadius: radius?.bottomRight,
551 borderBottomLeftRadius: radius?.bottomLeft,
552 borderTop: outerBorder.top || undefined,
553 borderRight: outerBorder.right || undefined,
554 borderBottom: outerBorder.bottom || undefined,
555 borderLeft: outerBorder.left || undefined,
556 boxSizing: "border-box",
557 overflow: "hidden",
558 }
559 : undefined;
560
561 if (previewActive && previewReady) {
562 // Read-only preview: PrimaryTable brings the search input, sort
563 // banner, pagination navigation and responsive modes with it.
564 return (
565 <figure
566 {...blockProps}
567 style={{
568 ...((blockProps as { style?: CSSProperties }).style || {}),
569 ...spacing,
570 }}
571 >
572 <PrimaryTable />
573 <TableCaption isSelected={props.isSelected} />
574 <TablebergControls {...proProps} />
575 </figure>
576 );
577 }
578
579 return (
580 <figure
581 {...blockProps}
582 style={{
583 ...((blockProps as { style?: CSSProperties }).style || {}),
584 ...spacing,
585 }}
586 >
587 <SearchInput />
588 {paginationActive && paginationCss && (
589 <style>{paginationCss}</style>
590 )}
591 {innerBorderCss && <style>{innerBorderCss}</style>}
592 <div style={radiusWrapperStyle}>
593 <div className="tableberg-native-table-scroll">
594 <table className="tableberg-table" style={tableStyle}>
595 {columnWidths && (
596 <colgroup>
597 {columnWidths.map((width, index) => (
598 <col
599 key={index}
600 style={width ? { width } : undefined}
601 />
602 ))}
603 </colgroup>
604 )}
605 <tbody {...innerBlocksProps} />
606 </table>
607 </div>
608 </div>
609 {paginationActive && (
610 <PaginationNavigation filteredRowCount={dataRowCount} />
611 )}
612 <TableCaption isSelected={props.isSelected} />
613 <TablebergControls {...proProps} />
614 </figure>
615 );
616 }
617
618 /**
619 * The store powers the (reused) inspector controls sidebar in native mode:
620 * it mirrors table-level attrs both ways but never syncs cell/row content
621 * (that lives in the block tree).
622 */
623 function NativeTableEditWithProviders(props: BlockEditProps<BlockAttrs>) {
624 if (props.attributes.isExample) {
625 return (
626 <img
627 src={exampleImage}
628 style={{ maxWidth: "100%" }}
629 alt="Tableberg table"
630 />
631 );
632 }
633
634 return (
635 <TableStoreProvider
636 clientId={props.clientId}
637 attributes={props.attributes}
638 setAttributes={props.setAttributes}
639 contentSyncDisabled
640 >
641 <QueryClientProvider client={queryClient}>
642 <NativeTableEdit {...props} />
643 </QueryClientProvider>
644 </TableStoreProvider>
645 );
646 }
647
648 function nativeSave() {
649 return <InnerBlocks.Content />;
650 }
651
652 /**
653 * The v3 (faux era) saved markup: a <table> shim wrapping null-saving shim
654 * blocks. Matching it here lets any v3 markup that bypasses the PHP REST
655 * migration (patterns, reusable blocks, cross-post paste) migrate natively.
656 */
657 const deprecatedV3 = {
658 attributes: metadata.attributes,
659 supports: metadata.supports,
660 isEligible(attrs: Partial<BlockAttrs>) {
661 return isV3OrOlder(attrs);
662 },
663 save() {
664 const blockProps = useBlockProps.save();
665 const innerBlocksProps = useInnerBlocksProps.save(blockProps);
666 return <table {...innerBlocksProps} />;
667 },
668 migrate(attrs: Partial<BlockAttrs>) {
669 return [toV4Attrs(attrs), buildRowBlocksFromV3(attrs)];
670 },
671 };
672
673 export function registerNativeTableBlock() {
674 registerBlockType(metadata.name, {
675 title: metadata.title,
676 icon: blockIcon,
677 category: metadata.category,
678 attributes:
679 metadata.attributes as BlockConfig<BlockAttrs>["attributes"],
680 providesContext: {
681 "tableberg/tableConfig": "table",
682 "tableberg/cellDefaults": "cellDefaults",
683 } as any,
684 transforms,
685 edit: NativeTableEditWithProviders,
686 save: nativeSave,
687 deprecated: [deprecatedV3],
688 } as any);
689 }
690