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

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

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