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

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

837 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { CSSProperties, ReactNode, useState } from "react";
2 import {
3 BlockControls,
4 InnerBlocks,
5 InspectorControls,
6 store as blockEditorStore,
7 useBlockProps,
8 useInnerBlocksProps,
9 } from "@wordpress/block-editor";
10 import { BlockEditProps, registerBlockType } from "@wordpress/blocks";
11 import { useDispatch, useRegistry, useSelect } from "@wordpress/data";
12 import {
13 PanelBody,
14 SelectControl,
15 ToggleControl,
16 ToolbarDropdownMenu,
17 } from "@wordpress/components";
18 import { __ } from "@wordpress/i18n";
19 import {
20 arrowRight,
21 table as tableIcon,
22 tableColumnAfter,
23 tableColumnBefore,
24 tableColumnDelete,
25 tableRowAfter,
26 tableRowBefore,
27 tableRowDelete,
28 } from "@wordpress/icons";
29 import blockIcon from "@tableberg/shared/icons/tableberg";
30
31 import metadata from "./block.json";
32 import {
33 ColorControl,
34 BorderControl,
35 SpacingControlSingle,
36 } from "@tableberg/components";
37 import LockedControl from "../../components/LockedControl";
38 import { UpsellEnhancedModal } from "../../components/UpsellModal";
39
40 import { useTableStore } from "../../store";
41 import { isProAvailable } from "../../pro-status";
42 import {
43 deleteColumn,
44 deleteRow,
45 getCellContext,
46 insertColumn,
47 insertRow,
48 mergeCells,
49 splitCell,
50 } from "../table/table-ops";
51 import { cellColumn, GridRow } from "../table/grid-model";
52 import { withMoveLock } from "../move-lock";
53 import { CellDimensionsControls } from "./dimensions";
54 import {
55 CellColumnBackgroundControls,
56 ColumnBackgroundContext,
57 } from "./row-column-background";
58 import {
59 CellColumnBorderControls,
60 ColumnBorderContext,
61 } from "./column-border";
62
63 import {
64 Border,
65 CellKey,
66 getCellKey,
67 RibbonConfig,
68 Span,
69 TableCellStylesType,
70 TableConfig,
71 } from "../../attributes";
72
73 const EMPTY_BORDER: Border = { top: "", right: "", bottom: "", left: "" };
74
75 export interface CellBlockAttrs {
76 span?: Span;
77 styles?: Partial<TableCellStylesType>;
78 ribbon?: RibbonConfig;
79 className?: string;
80 }
81
82 const ELEMENT_BLOCKS = [
83 "tableberg/text",
84 "tableberg/button",
85 "tableberg/image",
86 "tableberg/list",
87 "tableberg/styled-list",
88 "tableberg/icon",
89 "tableberg/star-rating",
90 "tableberg/custom-html",
91 ];
92
93 /**
94 * Whether this cell sits in the header or footer row, derived from the cell's
95 * position in the block tree + the table config provided via block context.
96 */
97 function useRowType(
98 clientId: string,
99 tableConfig: TableConfig | undefined
100 ): "header" | "footer" | "body" {
101 return useSelect(
102 select => {
103 if (!tableConfig?.headerEnabled && !tableConfig?.footerEnabled) {
104 return "body";
105 }
106
107 const be = select(blockEditorStore) as any;
108 const rowClientId = be.getBlockRootClientId(clientId);
109 if (!rowClientId) {
110 return "body";
111 }
112 const tableClientId = be.getBlockRootClientId(rowClientId);
113 const rowIndex = be.getBlockIndex(rowClientId);
114 const rowCount = be.getBlockCount(tableClientId);
115
116 if (tableConfig.headerEnabled && rowIndex === 0) {
117 return "header";
118 }
119 if (tableConfig.footerEnabled && rowIndex === rowCount - 1) {
120 return "footer";
121 }
122 return "body";
123 },
124 [clientId, tableConfig?.headerEnabled, tableConfig?.footerEnabled]
125 );
126 }
127
128 type RowBackgroundStyleKey =
129 | "headerBackgroundColor"
130 | "footerBackgroundColor"
131 | "evenRowBackgroundColor"
132 | "oddRowBackgroundColor";
133
134 /**
135 * Which of the table's row-position background defaults applies to this
136 * cell: header/footer rows take their own colour, other rows alternate
137 * even/odd by position among the data rows — header/footer don't consume a
138 * slot in that count, matching `TableRenderer.php`'s equivalent resolution.
139 */
140 function useRowBackgroundStyleKey(
141 clientId: string,
142 tableConfig: TableConfig | undefined
143 ): RowBackgroundStyleKey {
144 return useSelect(
145 select => {
146 const be = select(blockEditorStore) as any;
147 const rowClientId = be.getBlockRootClientId(clientId);
148 if (!rowClientId) {
149 return "oddRowBackgroundColor";
150 }
151 const tableClientId = be.getBlockRootClientId(rowClientId);
152 const rowIndex = be.getBlockIndex(rowClientId);
153 const rowCount = be.getBlockCount(tableClientId);
154
155 if (tableConfig?.headerEnabled && rowIndex === 0) {
156 return "headerBackgroundColor";
157 }
158 if (tableConfig?.footerEnabled && rowIndex === rowCount - 1) {
159 return "footerBackgroundColor";
160 }
161
162 const dataRowPosition = tableConfig?.headerEnabled
163 ? rowIndex - 1
164 : rowIndex;
165
166 return dataRowPosition % 2 === 0
167 ? "oddRowBackgroundColor"
168 : "evenRowBackgroundColor";
169 },
170 [clientId, tableConfig?.headerEnabled, tableConfig?.footerEnabled]
171 );
172 }
173
174 /** Shape of the entries the pro plugin adds to this menu. */
175 export interface CellGridPosition {
176 rowIndex: number;
177 column: number;
178 rowCount: number;
179 columnCount: number;
180 /** A merged cell ties positions together, so moves are off the table. */
181 hasMergedCells: boolean;
182 }
183
184 export interface CellToolbarControl {
185 icon: unknown;
186 title: string;
187 /** Left out when it returns false, e.g. "move up" on the first row. */
188 isAvailable?: (position: CellGridPosition) => boolean;
189 onClick: (context: {
190 registry: any;
191 tableClientId: string;
192 rowIndex: number;
193 column: number;
194 }) => void;
195 }
196
197 function CellTableToolbar({
198 clientId,
199 hasSpan,
200 // Injected by the pro plugin; empty when pro is not installed.
201 proToolbarControls = [],
202 }: {
203 clientId: string;
204 hasSpan: boolean;
205 proToolbarControls?: CellToolbarControl[];
206 }) {
207 const registry = useRegistry() as any;
208 const isPro = isProAvailable();
209 const [showDuplicateUpsell, setShowDuplicateUpsell] = useState(false);
210 const [showMoveUpsell, setShowMoveUpsell] = useState(false);
211
212 // Cells for a merge: the modifier-click marquee (works across rows) or
213 // the native sibling multi-select (same-row shift selection).
214 const marqueeCellIds = useTableStore(state => state.nativeSelectedCells);
215 const setNativeSelectedCells = useTableStore(
216 state => state.setNativeSelectedCells
217 );
218 const nativeMultiSelectIds = useSelect(select => {
219 const be = select(blockEditorStore) as any;
220 const ids: string[] = be.getMultiSelectedBlockClientIds();
221 return ids.filter(
222 id => be.getBlockName(id) === "tableberg/cell"
223 );
224 }, []);
225 const selectedCellIds =
226 marqueeCellIds.length > 1 ? marqueeCellIds : nativeMultiSelectIds;
227
228 // Where this cell sits, so entries that only make sense somewhere else
229 // in the grid (moving the first row up, say) can be left out.
230 const gridPosition = useSelect(
231 select => {
232 const be = select(blockEditorStore) as any;
233 const rowClientId = be.getBlockRootClientId(clientId);
234 const tableClientId = rowClientId
235 ? be.getBlockRootClientId(rowClientId)
236 : null;
237
238 if (!rowClientId || !tableClientId) {
239 return null;
240 }
241
242 const rows: { rowSpan: number; colSpan: number; id: string }[][] = (
243 be.getBlock(tableClientId)?.innerBlocks ?? []
244 )
245 .filter((b: any) => b.name === "tableberg/row")
246 .map((rowBlock: any) =>
247 rowBlock.innerBlocks
248 .filter((b: any) => b.name === "tableberg/cell")
249 .map((cellBlock: any) => ({
250 id: cellBlock.clientId,
251 rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1,
252 colSpan: cellBlock.attributes?.span?.colSpan ?? 1,
253 }))
254 );
255
256 const column = cellColumn(rows, clientId);
257
258 if (column === null) {
259 return null;
260 }
261
262 return {
263 rowIndex: be.getBlockIndex(rowClientId),
264 column,
265 rowCount: rows.length,
266 columnCount: rows[0]?.length ?? 0,
267 hasMergedCells: rows
268 .flat()
269 .some(cell => cell.rowSpan > 1 || cell.colSpan > 1),
270 } as CellGridPosition;
271 },
272 [clientId]
273 );
274
275 const withContext = (
276 fn: (
277 ctx: NonNullable<ReturnType<typeof getCellContext>>
278 ) => void
279 ) => () => {
280 const ctx = getCellContext(registry, clientId);
281 if (ctx && ctx.column !== null) {
282 fn(ctx);
283 }
284 };
285
286 const controls = [
287 {
288 icon: tableRowBefore,
289 title: __("Insert row above", "tableberg"),
290 onClick: withContext(ctx =>
291 insertRow(registry, ctx.tableClientId, ctx.rowIndex)
292 ),
293 },
294 {
295 icon: tableRowAfter,
296 title: __("Insert row below", "tableberg"),
297 onClick: withContext(ctx =>
298 insertRow(registry, ctx.tableClientId, ctx.rowIndex + 1)
299 ),
300 },
301 {
302 icon: tableRowDelete,
303 title: __("Delete row", "tableberg"),
304 onClick: withContext(ctx =>
305 deleteRow(registry, ctx.tableClientId, ctx.rowIndex)
306 ),
307 },
308 {
309 icon: tableColumnBefore,
310 title: __("Insert column before", "tableberg"),
311 onClick: withContext(ctx =>
312 insertColumn(registry, ctx.tableClientId, ctx.column!)
313 ),
314 },
315 {
316 icon: tableColumnAfter,
317 title: __("Insert column after", "tableberg"),
318 onClick: withContext(ctx => {
319 const cell = ctx.rows
320 .flat()
321 .find(c => c.id === clientId);
322 insertColumn(
323 registry,
324 ctx.tableClientId,
325 ctx.column! + (cell?.colSpan ?? 1)
326 );
327 }),
328 },
329 {
330 icon: tableColumnDelete,
331 title: __("Delete column", "tableberg"),
332 onClick: withContext(ctx =>
333 deleteColumn(registry, ctx.tableClientId, ctx.column!)
334 ),
335 },
336 // Duplicate row/column and the ribbon are pro features: pro supplies
337 // these entries and free only resolves the grid position for them.
338 // Without pro they are replaced by upsell entries below.
339 ...proToolbarControls
340 .filter(control =>
341 control.isAvailable && gridPosition
342 ? control.isAvailable(gridPosition)
343 : !control.isAvailable
344 )
345 .map(control => ({
346 icon: control.icon as any,
347 title: control.title,
348 onClick: withContext(ctx =>
349 control.onClick({
350 registry,
351 tableClientId: ctx.tableClientId,
352 rowIndex: ctx.rowIndex,
353 column: ctx.column!,
354 })
355 ),
356 })),
357 ...(proToolbarControls.length === 0 && !isPro
358 ? [
359 {
360 icon: tableRowAfter,
361 title: __("Duplicate row (Pro)", "tableberg"),
362 onClick: () => setShowDuplicateUpsell(true),
363 },
364 {
365 icon: tableColumnAfter,
366 title: __("Duplicate column (Pro)", "tableberg"),
367 onClick: () => setShowDuplicateUpsell(true),
368 },
369 {
370 icon: arrowRight,
371 title: __("Move column (Pro)", "tableberg"),
372 onClick: () => setShowMoveUpsell(true),
373 },
374 ]
375 : []),
376 ...(selectedCellIds.length > 1
377 ? [
378 {
379 icon: tableIcon,
380 title: __("Merge cells", "tableberg"),
381 onClick: withContext(ctx => {
382 const merged = mergeCells(
383 registry,
384 ctx.tableClientId,
385 selectedCellIds
386 );
387 if (merged) {
388 setNativeSelectedCells([]);
389 }
390 }),
391 },
392 ]
393 : []),
394 ...(hasSpan
395 ? [
396 {
397 icon: tableIcon,
398 title: __("Split cell", "tableberg"),
399 onClick: withContext(ctx =>
400 splitCell(registry, ctx.tableClientId, clientId)
401 ),
402 },
403 ]
404 : []),
405 ];
406
407 return (
408 <>
409 <BlockControls group="block">
410 <ToolbarDropdownMenu
411 icon={tableIcon}
412 label={__("Edit table", "tableberg")}
413 controls={controls}
414 />
415 </BlockControls>
416 {showDuplicateUpsell && (
417 <UpsellEnhancedModal
418 onClose={() => setShowDuplicateUpsell(false)}
419 selected="duplicate-row-col"
420 />
421 )}
422 {showMoveUpsell && (
423 <UpsellEnhancedModal
424 onClose={() => setShowMoveUpsell(false)}
425 selected="move-row-col"
426 />
427 )}
428 </>
429 );
430 }
431
432 // Per-cell style overrides (the table-wide defaults live on the table
433 // block's sidebar; these win over them for this one cell).
434 function CellInspectorControls({
435 attributes,
436 setAttributes,
437 defaultElementGap,
438 // Controls injected by the pro plugin; null when pro is not installed.
439 CellBackgroundControl = null,
440 CellRibbonPanel = null,
441 CellRibbonColorControls = null,
442 CellBorderControl = null,
443 CellEmptyControl = null,
444 }: Pick<BlockEditProps<CellBlockAttrs>, "attributes" | "setAttributes"> & {
445 defaultElementGap?: string;
446 CellBackgroundControl?: ReactNode;
447 CellRibbonPanel?: ReactNode;
448 CellRibbonColorControls?: ReactNode;
449 CellBorderControl?: ReactNode;
450 CellEmptyControl?: ReactNode;
451 }) {
452 const styles = attributes.styles ?? {};
453 const update = (patch: Partial<TableCellStylesType>) =>
454 setAttributes({ styles: { ...styles, ...patch } });
455
456 const ribbon = attributes.ribbon;
457
458 const isPro = isProAvailable();
459
460 const alignmentOptions = [
461 { label: __("Default", "tableberg"), value: "" },
462 { label: __("Top", "tableberg"), value: "top" },
463 { label: __("Middle", "tableberg"), value: "middle" },
464 { label: __("Bottom", "tableberg"), value: "bottom" },
465 ];
466
467 return (
468 <>
469 {/*
470 * The colour control renders a ToolsPanel item, so it only shows
471 * up inside a ToolsPanel. The editor's own "Color" group is one;
472 * a plain PanelBody is not, and the control silently renders
473 * nothing there.
474 */}
475 <InspectorControls group="color">
476 {/*
477 * A background on one cell is a pro feature: pro hands the
478 * real control down as a prop. Free has no implementation of
479 * its own, so without pro the dead placeholder below is all
480 * there is.
481 */}
482 {CellBackgroundControl && CellBackgroundControl}
483 {!CellBackgroundControl && !isPro && (
484 <LockedControl isEnhanced selected="cell-bg">
485 <ColorControl
486 label={__("Background Color", "tableberg")}
487 value=""
488 onChange={() => null}
489 onDeselect={() => null}
490 />
491 </LockedControl>
492 )}
493 {/* The ribbon's colours, while this cell has a ribbon. */}
494 {CellRibbonColorControls}
495 </InspectorControls>
496
497 {/*
498 * A border on one cell is a pro feature: pro hands the real
499 * control down as a prop. Free has no implementation of its
500 * own, so without pro the dead placeholder below is all there
501 * is. Same "Border" group (Styles tab) the row/column border
502 * controls use.
503 */}
504 <InspectorControls group="border">
505 {CellBorderControl && CellBorderControl}
506 {!CellBorderControl && !isPro && (
507 <LockedControl isEnhanced selected="cell-border">
508 <BorderControl
509 label={__("Border", "tableberg")}
510 value={EMPTY_BORDER}
511 hasValue={() => false}
512 onChange={() => null}
513 onDeselect={() => null}
514 />
515 </LockedControl>
516 )}
517 </InspectorControls>
518
519 <InspectorControls>
520 <PanelBody title={__("Cell Settings", "tableberg")}>
521 <SpacingControlSingle
522 label={__("Block Spacing", "tableberg")}
523 value={
524 styles.elementGap ??
525 defaultElementGap ??
526 "var(--wp--preset--spacing--20)"
527 }
528 onChange={elementGap => update({ elementGap })}
529 style={{ marginBottom: "24px" }}
530 />
531 {isPro ? (
532 <SelectControl
533 label={__("Vertical Alignment", "tableberg")}
534 value={(styles.verticalAlign as string) ?? ""}
535 options={alignmentOptions}
536 onChange={verticalAlign =>
537 update({
538 verticalAlign: (verticalAlign ||
539 undefined) as TableCellStylesType["verticalAlign"],
540 })
541 }
542 />
543 ) : (
544 <LockedControl isEnhanced selected="cell-bg">
545 <SelectControl
546 label={__("Vertical Alignment", "tableberg")}
547 value=""
548 options={alignmentOptions}
549 onChange={() => null}
550 />
551 </LockedControl>
552 )}
553 {/*
554 * Emptying a cell (keeping its background/border but
555 * leaving its content out on the frontend only) is a pro
556 * feature: pro hands the toggle down. Free has no
557 * implementation of its own.
558 */}
559 {CellEmptyControl}
560 {!CellEmptyControl && !isPro && (
561 <LockedControl isEnhanced selected="cell-empty">
562 <ToggleControl
563 checked={false}
564 label={__("Empty Cell", "tableberg")}
565 onChange={() => null}
566 />
567 </LockedControl>
568 )}
569 </PanelBody>
570 {/*
571 * The ribbon is a pro feature: pro hands the whole panel down.
572 * Free has no ribbon implementation of its own.
573 */}
574 {CellRibbonPanel}
575 {!CellRibbonPanel && !isPro && (
576 <PanelBody title={__("Ribbon", "tableberg")}>
577 <LockedControl isEnhanced selected="ribbon">
578 <ToggleControl
579 checked={false}
580 label={__("Enable Ribbon", "tableberg")}
581 onChange={() => null}
582 />
583 </LockedControl>
584 </PanelBody>
585 )}
586 </InspectorControls>
587 </>
588 );
589 }
590
591 function CellEdit(
592 props: BlockEditProps<CellBlockAttrs> & {
593 context?: Record<string, unknown>;
594 // Injected by the pro plugin's editor.BlockEdit wrapper.
595 CellBackgroundControl?: ReactNode;
596 CellRibbonPanel?: ReactNode;
597 CellRibbonColorControls?: ReactNode;
598 CellRibbonOverlay?: ReactNode;
599 CellBorderControl?: ReactNode;
600 CellEmptyControl?: ReactNode;
601 CellEmptyBadge?: ReactNode;
602 cellStyles?: CSSProperties;
603 cellProToolbarControls?: CellToolbarControl[];
604 ProColumnBackgroundContent?: (
605 ctx: ColumnBackgroundContext
606 ) => ReactNode;
607 ProColumnBorderContent?: (ctx: ColumnBorderContext) => ReactNode;
608 }
609 ) {
610 const { attributes, clientId, context, isSelected } = props;
611 const proExtensionActive = props.cellStyles !== undefined;
612
613 const tableConfig = context?.["tableberg/tableConfig"] as
614 | TableConfig
615 | undefined;
616
617 // Modifier-click marquee: Cmd/Ctrl+click toggles cells into a cross-row
618 // selection for merging; a plain click anywhere clears it.
619 const marqueeCellIds = useTableStore(state => state.nativeSelectedCells);
620 const setNativeSelectedCells = useTableStore(
621 state => state.setNativeSelectedCells
622 );
623 const { selectBlock } = useDispatch(blockEditorStore) as any;
624 const isInMarquee = marqueeCellIds.includes(clientId);
625
626 const onCellMouseDown = (event: React.MouseEvent) => {
627 if (event.metaKey || event.ctrlKey || event.shiftKey) {
628 // Capture-phase: keep Gutenberg's own modifier multi-select from
629 // hijacking the marquee.
630 event.preventDefault();
631 event.stopPropagation();
632 event.nativeEvent.stopImmediatePropagation?.();
633
634 const next = isInMarquee
635 ? marqueeCellIds.filter(id => id !== clientId)
636 : [...marqueeCellIds, clientId];
637 setNativeSelectedCells(next);
638 // Keep a cell block selected so the merge toolbar is reachable.
639 selectBlock(clientId);
640 return;
641 }
642
643 if (marqueeCellIds.length > 0) {
644 setNativeSelectedCells([]);
645 }
646 };
647 const cellDefaults = context?.["tableberg/cellDefaults"] as
648 | { styles: TableCellStylesType }
649 | undefined;
650 // A pro attribute of the parent row block, read via context since a
651 // cell has no direct reference to its row. Only relevant here to know
652 // whether to skip the table-wide default below.
653 const rowBackgroundColor = proExtensionActive
654 ? (context?.["tableberg/rowBackgroundColor"] as string | undefined)
655 : undefined;
656
657 const rowType = useRowType(clientId, tableConfig);
658 const Tag = rowType === "body" ? "td" : "th";
659 const rowBackgroundStyleKey = useRowBackgroundStyleKey(
660 clientId,
661 tableConfig
662 );
663
664 // Minimal cell styling for the editor MVP: defaults cascade + per-cell
665 // overrides. Full parity (borders, radius, orientation) lands in Phase 4.
666 const defaults = cellDefaults?.styles;
667 const styles = attributes.styles;
668 const cellStyle: CSSProperties = {
669 paddingTop: styles?.padding?.top ?? defaults?.padding?.top,
670 paddingRight: styles?.padding?.right ?? defaults?.padding?.right,
671 paddingBottom: styles?.padding?.bottom ?? defaults?.padding?.bottom,
672 paddingLeft: styles?.padding?.left ?? defaults?.padding?.left,
673 // The per-cell background is a pro attribute pro renders through
674 // `cellStyles` below; free only knows the table-wide defaults. Skip
675 // all of them when the row has its own colour — a cell painting a
676 // table default over itself would otherwise always hide the
677 // `<tr>`'s. A cell/column colour (below, via `cellStyles`) still
678 // wins over either one, applied after this. Header/footer/even/odd
679 // take priority over the plain common default when set.
680 backgroundColor: rowBackgroundColor
681 ? undefined
682 : (defaults?.[rowBackgroundStyleKey] ||
683 defaults?.backgroundColor ||
684 undefined),
685 // A cell's own vertical alignment is pro (the frontend only applies it
686 // through pro's licensed filter), so without pro show the table default.
687 verticalAlign:
688 ((isProAvailable() ? styles?.verticalAlign : undefined) as
689 | CSSProperties["verticalAlign"]
690 | undefined) ??
691 (defaults?.verticalAlign as CSSProperties["verticalAlign"]),
692 // The per-cell/column border is a pro attribute pro renders through
693 // `cellStyles` below; free only knows the table-wide default.
694 borderTop: defaults?.border?.top || undefined,
695 borderRight: defaults?.border?.right || undefined,
696 borderBottom: defaults?.border?.bottom || undefined,
697 borderLeft: defaults?.border?.left || undefined,
698 // Per-cell radius only; the table-level radius is clipped on the
699 // wrapper (border-radius on collapsed cells is ignored by browsers).
700 borderTopLeftRadius: styles?.borderRadius?.topLeft,
701 borderTopRightRadius: styles?.borderRadius?.topRight,
702 borderBottomRightRadius: styles?.borderRadius?.bottomRight,
703 borderBottomLeftRadius: styles?.borderRadius?.bottomLeft,
704 };
705
706 // Elements layout inside the cell (orientation / gap / wrap), same
707 // cascade: per-cell override wins over the table-wide defaults. Applied
708 // to an inner wrapper — flex directly on the td would break the table
709 // row layout (display: table-cell is lost).
710 const orientation =
711 styles?.orientation ??
712 (proExtensionActive ? defaults?.orientation : undefined);
713 const elementGap = styles?.elementGap ?? defaults?.elementGap;
714 const wrap =
715 styles?.wrap ?? (proExtensionActive ? defaults?.wrap : undefined);
716 const contentStyle: CSSProperties = {
717 display: "flex",
718 flexDirection: orientation === "horizontal" ? "row" : "column",
719 gap: elementGap || undefined,
720 };
721 if (orientation === "horizontal") {
722 contentStyle.alignItems = "center";
723 contentStyle.flexWrap = wrap === "nowrap" ? "nowrap" : "wrap";
724 }
725
726 if (isInMarquee) {
727 cellStyle.boxShadow =
728 "inset 0 0 0 2px var(--wp-admin-theme-color, #3858e9)";
729 }
730
731 Object.assign(cellStyle, props.cellStyles ?? {});
732
733 const blockProps = useBlockProps({ style: cellStyle });
734 const innerBlocksProps = useInnerBlocksProps(
735 {
736 className: "tableberg-cell-content",
737 style: contentStyle,
738 },
739 {
740 allowedBlocks: ELEMENT_BLOCKS,
741 template: [["tableberg/text", {}]],
742 }
743 );
744
745 // Sticky first column (pro): the first cell block of each row sticks to
746 // the left while the canvas scrolls horizontally.
747 const stickyFirstCol =
748 proExtensionActive && !!tableConfig?.stickyFirstCol;
749 const isFirstColCell = useSelect(
750 select => {
751 if (!stickyFirstCol) {
752 return false;
753 }
754 const be = select(blockEditorStore) as any;
755 return be.getBlockIndex(clientId) === 0;
756 },
757 [clientId, stickyFirstCol]
758 );
759 if (isFirstColCell) {
760 cellStyle.position = "sticky";
761 cellStyle.left = 0;
762 cellStyle.zIndex = 1;
763 if (!cellStyle.backgroundColor) {
764 cellStyle.backgroundColor = "#fff";
765 }
766 }
767
768 const hasSpan =
769 (attributes.span?.rowSpan ?? 1) > 1 ||
770 (attributes.span?.colSpan ?? 1) > 1;
771
772 return (
773 <>
774 {isSelected && (
775 <CellTableToolbar
776 clientId={clientId}
777 hasSpan={hasSpan}
778 proToolbarControls={props.cellProToolbarControls}
779 />
780 )}
781 {isSelected && (
782 <CellInspectorControls
783 attributes={attributes}
784 setAttributes={props.setAttributes}
785 defaultElementGap={defaults?.elementGap}
786 CellBackgroundControl={props.CellBackgroundControl}
787 CellRibbonPanel={props.CellRibbonPanel}
788 CellRibbonColorControls={props.CellRibbonColorControls}
789 CellBorderControl={props.CellBorderControl}
790 CellEmptyControl={props.CellEmptyControl}
791 />
792 )}
793 {isSelected && <CellDimensionsControls clientId={clientId} />}
794 {isSelected && (
795 <CellColumnBackgroundControls
796 clientId={clientId}
797 cellDefaultsBackgroundColor={
798 defaults?.backgroundColor ?? ""
799 }
800 ProColumnBackgroundContent={
801 props.ProColumnBackgroundContent
802 }
803 />
804 )}
805 {isSelected && (
806 <CellColumnBorderControls
807 clientId={clientId}
808 cellDefaultsBorder={defaults?.border ?? EMPTY_BORDER}
809 ProColumnBorderContent={props.ProColumnBorderContent}
810 />
811 )}
812
813 <Tag
814 {...blockProps}
815 rowSpan={attributes.span?.rowSpan}
816 colSpan={attributes.span?.colSpan}
817 onMouseDownCapture={onCellMouseDown}
818 style={(blockProps as { style?: CSSProperties }).style}
819 >
820 <div {...innerBlocksProps} />
821 {props.CellRibbonOverlay}
822 {props.CellEmptyBadge}
823 </Tag>
824 </>
825 );
826 }
827
828 export function registerNativeCellBlock() {
829 registerBlockType(metadata.name, {
830 ...(metadata as any),
831 attributes: withMoveLock(metadata.attributes as any),
832 icon: blockIcon,
833 edit: CellEdit,
834 save: () => <InnerBlocks.Content />,
835 });
836 }
837