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

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

639 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useMemo, CSSProperties } from "react";
2 import classNames from "classnames";
3 import { useSelect } from "@wordpress/data";
4 import { useTableStore } from "../../store";
5 import { Cell } from "../cell";
6 import { SortPreviewBanner } from "../../components/SortPreviewBanner";
7 import { PaginationNavigation } from "../../components/PaginationNavigation";
8 import { SearchInput } from "../../components/SearchInput";
9 import { isProAvailable } from "../../pro-status";
10 import {
11 CellKey,
12 ResponsiveBreakpoint,
13 Span,
14 attrDefaults,
15 getCellKey,
16 } from "../../attributes";
17 import { sortRowsByColumn } from "../../sorting";
18 import { filterRowsBySearch } from "../../search";
19 import { getElementTextContent } from "../../elements";
20
21 const tableConfigDefaults = attrDefaults.table;
22
23 type PreviewDevice = "desktop" | "tablet" | "mobile";
24
25 type ResponsivePreviewStore = {
26 getDeviceType?: () => string;
27 __experimentalGetPreviewDeviceType?: () => string;
28 };
29
30 type PreviewCellRef = {
31 coords: CellKey;
32 span: Span;
33 key: string;
34 };
35
36 type LegacyResponsiveBreakpoint = Partial<ResponsiveBreakpoint> & {
37 direction?: "row" | "col";
38 headerAsCol?: boolean;
39 };
40
41 const zeroWidthBorderPattern = /^0(?:\.0+)?(?:[a-z%]+)?$/i;
42
43 function hasNonZeroCssValue(value?: string) {
44 const trimmed = value?.trim() || "";
45 return !!trimmed && !zeroWidthBorderPattern.test(trimmed);
46 }
47
48 function hasVisibleBorder(border: string) {
49 const trimmedBorder = border.trim();
50
51 if (!trimmedBorder) {
52 return false;
53 }
54
55 const [width = "", style = ""] = trimmedBorder.split(/\s+/, 3);
56
57 if (width === "none" || width === "hidden") {
58 return false;
59 }
60
61 if (zeroWidthBorderPattern.test(width)) {
62 return false;
63 }
64
65 if (style === "none" || style === "hidden") {
66 return false;
67 }
68
69 return true;
70 }
71
72 function normalizeResponsiveBreakpoint(
73 breakpoint: LegacyResponsiveBreakpoint | null | undefined,
74 fallback: ResponsiveBreakpoint
75 ): ResponsiveBreakpoint {
76 const normalized = {
77 ...fallback,
78 ...(breakpoint || {}),
79 } as ResponsiveBreakpoint;
80
81 normalized.transpose =
82 typeof breakpoint?.transpose === "boolean"
83 ? breakpoint.transpose
84 : breakpoint?.direction === "row";
85
86 normalized.repeatFirstCol =
87 typeof breakpoint?.repeatFirstCol === "boolean"
88 ? breakpoint.repeatFirstCol
89 : !!breakpoint?.headerAsCol;
90
91 return normalized;
92 }
93
94 function buildResponsivePreviewRows(
95 baseRows: PreviewCellRef[][],
96 maxItemsPerRow: number,
97 transformRowsToCols: boolean,
98 repeatFirstColumn: boolean
99 ): PreviewCellRef[][] {
100 if (baseRows.length === 0) {
101 return [];
102 }
103
104 const maxCols = baseRows.reduce((max, row) => Math.max(max, row.length), 0);
105
106 if (maxCols === 0) {
107 return [];
108 }
109
110 const matrix: Array<Array<PreviewCellRef | null>> = baseRows.map(row => {
111 const padded = row.map(cell => cell as PreviewCellRef | null);
112 while (padded.length < maxCols) {
113 padded.push(null);
114 }
115 return padded;
116 });
117
118 const sourceRows: Array<Array<PreviewCellRef | null>> = transformRowsToCols
119 ? Array.from({ length: maxCols }, (_, row) =>
120 matrix.map(column => column[row] || null)
121 )
122 : matrix;
123
124 const sourceCols = sourceRows[0]?.length || 0;
125 if (sourceCols === 0) {
126 return [];
127 }
128
129 const effectiveMax = repeatFirstColumn
130 ? Math.max(2, maxItemsPerRow)
131 : Math.max(1, maxItemsPerRow);
132
133 const outputRows: PreviewCellRef[][] = [];
134
135 if (!repeatFirstColumn) {
136 const groups = Math.ceil(sourceCols / effectiveMax);
137
138 for (let group = 0; group < groups; group++) {
139 const start = group * effectiveMax;
140 const end = Math.min(start + effectiveMax, sourceCols);
141
142 for (let row = 0; row < sourceRows.length; row++) {
143 const cells = sourceRows[row]
144 .slice(start, end)
145 .filter((cell): cell is PreviewCellRef => cell !== null);
146
147 if (cells.length > 0) {
148 outputRows.push(cells);
149 }
150 }
151 }
152
153 return outputRows;
154 }
155
156 const firstColumn = 0;
157 const dataChunkSize = Math.max(1, effectiveMax - 1);
158 const groups =
159 sourceCols <= 1 ? 1 : Math.ceil((sourceCols - 1) / dataChunkSize);
160
161 for (let group = 0; group < groups; group++) {
162 const start = 1 + group * dataChunkSize;
163 const end = Math.min(start + dataChunkSize, sourceCols);
164
165 for (let row = 0; row < sourceRows.length; row++) {
166 const firstCell = sourceRows[row][firstColumn];
167 const dataCells = sourceRows[row]
168 .slice(start, end)
169 .filter((cell): cell is PreviewCellRef => cell !== null);
170
171 const rowCells: PreviewCellRef[] = [];
172
173 if (firstCell) {
174 if (group === 0) {
175 rowCells.push(firstCell);
176 } else {
177 rowCells.push({
178 ...firstCell,
179 key: `${firstCell.key}-repeat-${group}`,
180 span: { rowSpan: 1, colSpan: 1 },
181 });
182 }
183 }
184
185 rowCells.push(...dataCells);
186
187 if (rowCells.length > 0) {
188 outputRows.push(rowCells);
189 }
190 }
191 }
192
193 return outputRows;
194 }
195
196 function getPreviewDeviceType(
197 rawDeviceType: string | undefined
198 ): PreviewDevice {
199 const normalized = (rawDeviceType || "desktop").toLowerCase();
200
201 if (normalized === "tablet") {
202 return "tablet";
203 }
204
205 if (normalized === "mobile") {
206 return "mobile";
207 }
208
209 return "desktop";
210 }
211
212 export const PrimaryTable = () => {
213 const isPro = isProAvailable();
214 const tableConfig = useTableStore(state => state.table);
215 const cells = useTableStore(state => state.cells);
216 const columns = useTableStore(state => state.columns);
217 const getCellSpan = useTableStore(state => state.getCellSpan);
218 const tableBorderRadius = useTableStore(
219 state => state.cellDefaults.styles.borderRadius
220 );
221 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
222 const previewSortColumn = useTableStore(state => state.previewSortColumn);
223 const previewSortOrder = useTableStore(state => state.previewSortOrder);
224
225 const currentPage = useTableStore(state => state.currentPage);
226 const setCurrentPage = useTableStore(state => state.setCurrentPage);
227 const paginationConfig = useTableStore(state => state.table.pagination!);
228
229 const searchTerm = useTableStore(state => state.searchTerm);
230 const searchConfig = useTableStore(state => state.table.search);
231
232 const previewDevice = useSelect(select => {
233 const editorStore = select("core/editor") as ResponsivePreviewStore;
234 const siteEditorStore = select(
235 "core/edit-site"
236 ) as ResponsivePreviewStore;
237 const postEditorStore = select(
238 "core/edit-post"
239 ) as ResponsivePreviewStore;
240
241 const rawDeviceType =
242 editorStore?.getDeviceType?.() ||
243 siteEditorStore?.__experimentalGetPreviewDeviceType?.() ||
244 postEditorStore?.__experimentalGetPreviewDeviceType?.();
245
246 return getPreviewDeviceType(rawDeviceType);
247 }, []);
248
249 const { rows: totalRows, cols: totalCols } = tableConfig;
250 const paginationEnabled = isPro && paginationConfig.enabled;
251 const searchEnabled = isPro && (searchConfig?.enabled || false);
252 const tableAlignment = tableConfig.tableAlignment || "left";
253 const tableWidthValue = (tableConfig.tableWidth || "auto").trim();
254 const customTableWidthAllowed =
255 tableWidthValue !== "auto" &&
256 tableWidthValue !== "wide" &&
257 tableWidthValue !== "full";
258 const tableWidth = customTableWidthAllowed ? tableWidthValue : "100%";
259 const wrapperAlignmentClass = customTableWidthAllowed
260 ? `justify-table-${tableAlignment}`
261 : "";
262 const defaultCellSpacing = tableConfigDefaults.cellSpacing!!;
263 const cellSpacing = tableConfig.cellSpacing || defaultCellSpacing;
264 const defaultTableBorder = tableConfigDefaults.tableBorder!!;
265 const tableBorder = tableConfig.tableBorder || defaultTableBorder;
266 const hasTableBorderTop = hasVisibleBorder(tableBorder.top);
267 const hasTableBorderRight = hasVisibleBorder(tableBorder.right);
268 const hasTableBorderBottom = hasVisibleBorder(tableBorder.bottom);
269 const hasTableBorderLeft = hasVisibleBorder(tableBorder.left);
270 const horizontalCellSpacing =
271 cellSpacing.horizontal || defaultCellSpacing.horizontal;
272 const verticalCellSpacing =
273 cellSpacing.vertical || defaultCellSpacing.vertical;
274 const isHorizontalSpacingZero = horizontalCellSpacing === "0";
275 const isVerticalSpacingZero = verticalCellSpacing === "0";
276 const hasCellSpacing = !isHorizontalSpacingZero || !isVerticalSpacingZero;
277 const tableClassName = [
278 "wp-block-tableberg",
279 hasTableBorderTop ? "tableberg-has-table-border-top" : "",
280 hasTableBorderRight ? "tableberg-has-table-border-right" : "",
281 hasTableBorderBottom ? "tableberg-has-table-border-bottom" : "",
282 hasTableBorderLeft ? "tableberg-has-table-border-left" : "",
283 hasCellSpacing ? "tableberg-has-cell-spacing" : "",
284 hasCellSpacing && isHorizontalSpacingZero
285 ? "tableberg-cell-spacing-horizontal-zero"
286 : "",
287 hasCellSpacing && isVerticalSpacingZero
288 ? "tableberg-cell-spacing-vertical-zero"
289 : "",
290 ]
291 .filter(Boolean)
292 .join(" ");
293
294 const activeResponsiveBreakpoint: ResponsiveBreakpoint | null =
295 previewDevice === "desktop"
296 ? null
297 : normalizeResponsiveBreakpoint(
298 tableConfig.responsive?.[
299 previewDevice
300 ] as LegacyResponsiveBreakpoint,
301 tableConfigDefaults.responsive![previewDevice]
302 );
303
304 const hasResponsivePreview =
305 !!activeResponsiveBreakpoint?.enabled &&
306 !!activeResponsiveBreakpoint?.mode;
307
308 const responsivePreviewClassName = !hasResponsivePreview
309 ? ""
310 : activeResponsiveBreakpoint?.mode === "scroll"
311 ? "tableberg-scroll-x"
312 : "";
313
314 const editorResponsiveStackCount = hasResponsivePreview
315 ? Math.max(1, activeResponsiveBreakpoint?.stackCount || 1)
316 : 1;
317
318 const wrapperClassName = classNames(
319 "tableberg-table-wrapper",
320 wrapperAlignmentClass,
321 responsivePreviewClassName
322 );
323
324 const filteredRowIndices = useMemo(() => {
325 if (!searchEnabled || !searchTerm.trim()) {
326 return Array.from({ length: totalRows }, (_, i) => i);
327 }
328
329 return filterRowsBySearch(
330 cells,
331 totalRows,
332 totalCols,
333 tableConfig,
334 searchTerm
335 );
336 }, [searchEnabled, searchTerm, cells, totalRows, totalCols, tableConfig]);
337
338 const sortedRowIndices = useMemo(() => {
339 if (!isPro || !sortPreviewMode || previewSortColumn === null) {
340 return filteredRowIndices;
341 }
342
343 const sortType = columns[previewSortColumn]?.sortable || "text";
344
345 if (searchEnabled && searchTerm.trim()) {
346 const { headerEnabled, footerEnabled } = tableConfig;
347 const headerRow = headerEnabled ? 0 : -1;
348 const footerRow = footerEnabled ? totalRows - 1 : -1;
349
350 const dataRows = filteredRowIndices.filter(
351 idx => idx !== headerRow && idx !== footerRow
352 );
353
354 const rowsWithValues = dataRows.map(rowIdx => {
355 const elements =
356 cells[`${rowIdx},${previewSortColumn}`]?.elements || [];
357 const rawValue =
358 elements.length > 0
359 ? getElementTextContent(elements[0])
360 : "";
361
362 let parsedValue: string | number | Date;
363 switch (sortType) {
364 case "number": {
365 const cleaned = rawValue.replace(/[^0-9.-]/g, "");
366 const num = parseFloat(cleaned);
367 parsedValue = isNaN(num) ? 0 : num;
368 break;
369 }
370 case "date": {
371 const date = new Date(rawValue);
372 parsedValue = isNaN(date.getTime())
373 ? new Date(0)
374 : date;
375 break;
376 }
377 case "text":
378 default:
379 parsedValue = rawValue.toLowerCase();
380 }
381
382 return { index: rowIdx, value: parsedValue };
383 });
384
385 rowsWithValues.sort(({ value: a }, { value: b }) => {
386 let result: number;
387 if (typeof a === "number" && typeof b === "number") {
388 result = a - b;
389 } else if (a instanceof Date && b instanceof Date) {
390 result = a.getTime() - b.getTime();
391 } else {
392 result = String(a).localeCompare(String(b));
393 }
394 return previewSortOrder === "asc" ? result : -result;
395 });
396
397 const result: number[] = [];
398 if (headerEnabled && filteredRowIndices.includes(0)) {
399 result.push(0);
400 }
401 for (const { index } of rowsWithValues) {
402 result.push(index);
403 }
404 if (footerEnabled && filteredRowIndices.includes(totalRows - 1)) {
405 result.push(totalRows - 1);
406 }
407
408 return result;
409 }
410
411 return sortRowsByColumn(
412 cells,
413 totalRows,
414 tableConfig,
415 previewSortColumn,
416 sortType,
417 previewSortOrder
418 );
419 }, [
420 sortPreviewMode,
421 previewSortColumn,
422 previewSortOrder,
423 filteredRowIndices,
424 cells,
425 columns,
426 tableConfig,
427 totalRows,
428 searchEnabled,
429 searchTerm,
430 isPro,
431 ]);
432
433 const rowOrder = useMemo(() => {
434 let rows = sortedRowIndices;
435
436 if (paginationEnabled) {
437 const { headerEnabled, footerEnabled } = tableConfig;
438
439 const headerRow = headerEnabled ? 0 : -1;
440 const footerRow = footerEnabled ? totalRows - 1 : -1;
441
442 const dataRowsInOrder = rows.filter(
443 idx => idx !== headerRow && idx !== footerRow
444 );
445
446 const { pageSize } = paginationConfig;
447 const startIdx = currentPage * pageSize;
448 const endIdx = Math.min(
449 startIdx + pageSize,
450 dataRowsInOrder.length
451 );
452
453 const maxPage = Math.max(
454 0,
455 Math.ceil(dataRowsInOrder.length / pageSize) - 1
456 );
457 if (currentPage > maxPage && maxPage >= 0) {
458 setCurrentPage(maxPage);
459 }
460
461 const pagedRows: number[] = [];
462
463 if (headerEnabled && rows.includes(0)) {
464 pagedRows.push(0);
465 }
466
467 for (let i = startIdx; i < endIdx; i++) {
468 pagedRows.push(dataRowsInOrder[i]);
469 }
470
471 if (footerEnabled && rows.includes(totalRows - 1)) {
472 pagedRows.push(totalRows - 1);
473 }
474
475 rows = pagedRows;
476 }
477
478 return rows;
479 }, [
480 sortedRowIndices,
481 paginationEnabled,
482 currentPage,
483 paginationConfig.pageSize,
484 tableConfig,
485 totalRows,
486 setCurrentPage,
487 ]);
488
489 const getOccupiedCellsForRow = (actualRow: number) => {
490 if (sortPreviewMode || paginationEnabled) {
491 return new Set<number>();
492 }
493
494 const occupied = new Set<number>();
495 for (const [key, cell] of Object.entries(cells)) {
496 const span = cell.span;
497 if (!span) {
498 continue;
499 }
500
501 const [cellRow, cellCol] = key.split(",").map(Number);
502 if (cellRow <= actualRow && cellRow + span.rowSpan > actualRow) {
503 for (let cs = 0; cs < span.colSpan; cs++) {
504 if (cellRow !== actualRow) {
505 occupied.add(cellCol + cs);
506 }
507 }
508 }
509 }
510 return occupied;
511 };
512
513 const tableRows: JSX.Element[] = [];
514 const baseRowsForResponsivePreview: PreviewCellRef[][] = [];
515 for (const actualRow of rowOrder) {
516 const rowCells: JSX.Element[] = [];
517 const rowCellsForPreview: PreviewCellRef[] = [];
518 const occupiedCols = getOccupiedCellsForRow(actualRow);
519
520 for (let col = 0; col < totalCols; col++) {
521 if (occupiedCols.has(col)) {
522 continue;
523 }
524
525 const cellKey = `${actualRow},${col}`;
526 const span = getCellSpan(cellKey as CellKey);
527
528 if (!sortPreviewMode && !paginationEnabled) {
529 for (let cs = 1; cs < span.colSpan; cs++) {
530 occupiedCols.add(col + cs);
531 }
532 }
533
534 rowCellsForPreview.push({
535 coords: getCellKey(actualRow, col),
536 span,
537 key: cellKey,
538 });
539
540 rowCells.push(
541 <Cell
542 key={cellKey}
543 span={span}
544 cellCoords={cellKey as CellKey}
545 />
546 );
547 }
548
549 baseRowsForResponsivePreview.push(rowCellsForPreview);
550 tableRows.push(<tr key={actualRow}>{rowCells}</tr>);
551 }
552
553 let renderedRows = tableRows;
554
555 if (hasResponsivePreview && activeResponsiveBreakpoint?.mode === "stack") {
556 const previewRows = buildResponsivePreviewRows(
557 baseRowsForResponsivePreview,
558 editorResponsiveStackCount,
559 activeResponsiveBreakpoint.transpose,
560 activeResponsiveBreakpoint.repeatFirstCol
561 );
562
563 renderedRows = previewRows.map((previewRow, rowIndex) => (
564 <tr key={`responsive-preview-row-${rowIndex}`}>
565 {previewRow.map((cell, cellIndex) => (
566 <Cell
567 key={`${cell.key}-${rowIndex}-${cellIndex}`}
568 span={{ rowSpan: 1, colSpan: 1 }}
569 cellCoords={cell.coords}
570 />
571 ))}
572 </tr>
573 ));
574 }
575
576 // The rounded wrapper owns only the explicit table border. Cell borders
577 // stay on cells so they do not create a second, unintended outer border.
578 const hasTableRadius = !!(
579 hasNonZeroCssValue(tableBorderRadius?.topLeft) ||
580 hasNonZeroCssValue(tableBorderRadius?.topRight) ||
581 hasNonZeroCssValue(tableBorderRadius?.bottomRight) ||
582 hasNonZeroCssValue(tableBorderRadius?.bottomLeft)
583 );
584 const tableRadiusWrapperStyle: CSSProperties | undefined = hasTableRadius
585 ? {
586 borderTopLeftRadius: tableBorderRadius?.topLeft,
587 borderTopRightRadius: tableBorderRadius?.topRight,
588 borderBottomRightRadius: tableBorderRadius?.bottomRight,
589 borderBottomLeftRadius: tableBorderRadius?.bottomLeft,
590 borderTop: tableBorder.top || undefined,
591 borderRight: tableBorder.right || undefined,
592 borderBottom: tableBorder.bottom || undefined,
593 borderLeft: tableBorder.left || undefined,
594 boxSizing: "border-box",
595 overflow: "hidden",
596 }
597 : undefined;
598
599 return (
600 <div className={wrapperClassName} style={tableRadiusWrapperStyle}>
601 <SearchInput />
602 <SortPreviewBanner />
603 <table
604 className={tableClassName}
605 data-tableberg-editor-preview={
606 hasResponsivePreview ? previewDevice : undefined
607 }
608 style={{
609 borderCollapse: hasCellSpacing ? "separate" : "collapse",
610 borderSpacing: hasCellSpacing
611 ? `${horizontalCellSpacing} ${verticalCellSpacing}`
612 : undefined,
613 width: tableWidth,
614 ...(customTableWidthAllowed
615 ? {
616 maxWidth: tableWidth,
617 }
618 : {}),
619 borderTop: hasTableRadius
620 ? undefined
621 : tableBorder.top || undefined,
622 borderRight: hasTableRadius
623 ? undefined
624 : tableBorder.right || undefined,
625 borderBottom: hasTableRadius
626 ? undefined
627 : tableBorder.bottom || undefined,
628 borderLeft: hasTableRadius
629 ? undefined
630 : tableBorder.left || undefined,
631 }}
632 >
633 <tbody>{renderedRows}</tbody>
634 </table>
635 <PaginationNavigation filteredRowCount={sortedRowIndices.length} />
636 </div>
637 );
638 };
639