import { ReactNode, useState } from "react";
import {
BlockControls,
InnerBlocks,
InspectorControls,
store as blockEditorStore,
useBlockProps,
useInnerBlocksProps,
} from "@wordpress/block-editor";
import { BlockEditProps, registerBlockType } from "@wordpress/blocks";
import { useDispatch, useRegistry, useSelect } from "@wordpress/data";
import metadata from "./block.json";
import {
PanelBody,
ToolbarDropdownMenu,
__experimentalUnitControl as UnitControl,
} from "@wordpress/components";
import {
tableRowAfter,
tableRowBefore,
tableRowDelete,
table as tableIcon,
} from "@wordpress/icons";
import { __ } from "@wordpress/i18n";
import {
ColorControl,
BorderControl,
BorderRadiusControl,
} from "@tableberg/components";
import blockIcon from "@tableberg/shared/icons/tableberg";
import LockedControl from "../../components/LockedControl";
import { UpsellEnhancedModal } from "../../components/UpsellModal";
import { isProAvailable } from "../../pro-status";
import { ColorControlProps } from "../../hooks";
import { Border, Corners } from "../../attributes";
import { deleteRow, insertRow } from "../table/table-ops";
import { withMoveLock } from "../move-lock";
const EMPTY_BORDER: Border = { top: "", right: "", bottom: "", left: "" };
const EMPTY_CORNERS: Corners = {
topLeft: "",
topRight: "",
bottomRight: "",
bottomLeft: "",
};
export interface RowBlockAttrs {
height?: string;
// Pro-owned: declared in the schema for preservation while pro is inactive.
// Free never writes it.
backgroundColor?: string;
textColor?: string;
border?: Border;
}
/** Everything pro's row colour controls need. */
export interface RowBackgroundContext {
rowBackgroundColorControl: ColorControlProps;
rowTextColorControl: ColorControlProps;
}
/** Where a row sits, for entries that only make sense elsewhere. */
export interface RowGridPosition {
rowIndex: number;
rowCount: number;
/** A merged cell ties rows together, so moves are off the table. */
hasMergedCells: boolean;
}
/** Toolbar entries pro adds to the row's "Edit row" menu. */
export interface RowToolbarControl {
icon: unknown;
title: string;
/** Left out when it returns false, e.g. "move up" on the first row. */
isAvailable?: (position: RowGridPosition) => boolean;
onClick: (context: {
registry: any;
tableClientId: string;
rowIndex: number;
}) => void;
}
/**
* The row's own "Edit row" menu: the same dropdown the cell has, with only
* the entries that act on a row. Rows carry no mover of their own (moving is
* a pro feature, see the block's lock), so this is where a row is added,
* removed or moved from.
*/
function RowTableToolbar({
clientId,
// Injected by the pro plugin; empty when pro is not installed.
proToolbarControls = [],
}: {
clientId: string;
proToolbarControls?: RowToolbarControl[];
}) {
const registry = useRegistry() as any;
const isPro = isProAvailable();
const [showUpsell, setShowUpsell] = useState<
"duplicate-row-col" | null
>(null);
const position = useSelect(
select => {
const be = select(blockEditorStore) as any;
const tableClientId = be.getBlockRootClientId(clientId);
if (!tableClientId) {
return null;
}
const rows = (be.getBlock(tableClientId)?.innerBlocks ?? []).filter(
(b: any) => b.name === "tableberg/row"
);
return {
tableClientId,
rowIndex: be.getBlockIndex(clientId),
rowCount: rows.length,
hasMergedCells: rows.some((rowBlock: any) =>
(rowBlock.innerBlocks ?? []).some(
(cellBlock: any) =>
(cellBlock.attributes?.span?.rowSpan ?? 1) > 1 ||
(cellBlock.attributes?.span?.colSpan ?? 1) > 1
)
),
};
},
[clientId]
);
if (!position) {
return null;
}
const { tableClientId, rowIndex } = position;
const controls = [
{
icon: tableRowBefore,
title: __("Insert row above", "tableberg"),
onClick: () => insertRow(registry, tableClientId, rowIndex),
},
{
icon: tableRowAfter,
title: __("Insert row below", "tableberg"),
onClick: () => insertRow(registry, tableClientId, rowIndex + 1),
},
{
icon: tableRowDelete,
title: __("Delete row", "tableberg"),
onClick: () => deleteRow(registry, tableClientId, rowIndex),
},
// Duplicating and moving a row are pro features: pro supplies these
// entries and free only resolves the row's position for them.
...proToolbarControls
.filter(control =>
control.isAvailable ? control.isAvailable(position) : true
)
.map(control => ({
icon: control.icon as any,
title: control.title,
onClick: () =>
control.onClick({ registry, tableClientId, rowIndex }),
})),
...(proToolbarControls.length === 0 && !isPro
? [
{
icon: tableRowAfter,
title: __("Duplicate row (Pro)", "tableberg"),
onClick: () => setShowUpsell("duplicate-row-col"),
},
]
: []),
];
return (
<>
{showUpsell && (
setShowUpsell(null)}
selected={showUpsell}
/>
)}
>
);
}
/** Everything pro's row border and corner controls need. */
export interface RowBorderContext {
rowBorderControlProps: {
label: string;
value: Border;
hasValue: () => boolean;
onChange: (newBorder: Border) => void;
onDeselect: () => void;
};
rowRadiusControlProps: {
label: string;
value: Corners;
hasValue: () => boolean;
onChange: (newRadius: Corners) => void;
onDeselect: () => void;
};
}
function RowEdit({
clientId,
attributes,
setAttributes,
isSelected,
// Injected by the pro plugin; undefined when pro is not installed. A
// render function rather than a plain node: claiming priority over a
// bulk-applied colour (even/odd, etc.) already sitting on this row's
// cells needs the block registry, which pro's editor.BlockEdit HOC
// can't reach from outside the tree.
ProRowBackgroundContent,
// Injected by the pro plugin; undefined when pro is not installed. A
// render function like the background one: the row's corners are the
// corners of its outermost cells, so rounding them needs the block
// registry, which pro's editor.BlockEdit HOC can't reach from outside
// the tree.
ProRowBorderContent,
// Injected by the pro plugin; empty when pro is not installed.
rowProToolbarControls,
}: BlockEditProps & {
ProRowBackgroundContent?: (ctx: RowBackgroundContext) => ReactNode;
ProRowBorderContent?: (ctx: RowBorderContext) => ReactNode;
rowProToolbarControls?: RowToolbarControl[];
}) {
const registry = useRegistry() as any;
const { updateBlockAttributes } = useDispatch(blockEditorStore) as any;
// The schema keeps pro-owned values while pro is inactive, but the free
// editor must not preview behavior the frontend is currently gating out.
const proExtensionActive = Boolean(
ProRowBackgroundContent || ProRowBorderContent
);
const border = proExtensionActive
? (attributes.border ?? EMPTY_BORDER)
: EMPTY_BORDER;
const blockProps = useBlockProps({
style: {
height: attributes.height || undefined,
backgroundColor: proExtensionActive
? attributes.backgroundColor || undefined
: undefined,
// Inherited by everything in the row's cells that has no colour
// of its own, the way the core Column block does it.
color: proExtensionActive
? attributes.textColor || undefined
: undefined,
borderTop: border.top || undefined,
borderRight: border.right || undefined,
borderBottom: border.bottom || undefined,
borderLeft: border.left || undefined,
},
});
const innerBlocksProps = useInnerBlocksProps(blockProps, {
allowedBlocks: ["tableberg/cell"],
renderAppender: false,
});
// A colour already bulk-applied to this row's own cells (even/odd, a
// column colour, or an older individual choice) would otherwise sit on
// top of the row's own background and hide it, since each cell paints
// its own background over the row's. Claiming a colour for the row
// clears it back off those cells so the row's colour actually shows.
const clearCellBackgrounds = () => {
const be = registry.select(blockEditorStore);
const cellIds: string[] = (be.getBlock(clientId)?.innerBlocks ?? [])
.filter((b: any) => b.name === "tableberg/cell")
.map((b: any) => b.clientId);
registry.batch(() => {
cellIds.forEach(id => {
const currentStyles = be.getBlockAttributes(id)?.styles ?? {};
const { backgroundColor: _legacy, ...styles } = currentStyles;
updateBlockAttributes(id, {
backgroundColor: null,
styles,
});
});
});
};
const rowBackgroundContext: RowBackgroundContext = {
rowBackgroundColorControl: {
label: __("Row Background Color", "tableberg"),
value: attributes.backgroundColor ?? "",
onChange: (value: string) => {
setAttributes({ backgroundColor: value || undefined });
if (value) {
clearCellBackgrounds();
}
},
onDeselect: () => setAttributes({ backgroundColor: undefined }),
},
rowTextColorControl: {
label: __("Row Text Color", "tableberg"),
value: attributes.textColor ?? "",
onChange: (value: string) =>
setAttributes({ textColor: value || undefined }),
onDeselect: () => setAttributes({ textColor: undefined }),
},
};
// A row has no corners of its own to round: a `border-radius` on a
// `` does nothing under border-collapse. What reads as a rounded row
// is its first cell rounded on the left and its last cell on the right,
// so the control resolves and writes those cells' own corners.
const rowCellIds = (): string[] =>
(registry.select(blockEditorStore).getBlock(clientId)?.innerBlocks ??
[])
.filter((b: any) => b.name === "tableberg/cell")
.map((b: any) => b.clientId);
const readRowRadius = (): Corners => {
const be = registry.select(blockEditorStore);
const ids = rowCellIds();
if (ids.length === 0) {
return EMPTY_CORNERS;
}
const cornersOf = (id: string): Corners =>
(be.getBlockAttributes(id)?.styles?.borderRadius ??
EMPTY_CORNERS) as Corners;
const first = cornersOf(ids[0]);
const last = cornersOf(ids[ids.length - 1]);
return {
topLeft: first.topLeft ?? "",
topRight: last.topRight ?? "",
bottomRight: last.bottomRight ?? "",
bottomLeft: first.bottomLeft ?? "",
};
};
const applyRowRadius = (newRadius: Corners) => {
const be = registry.select(blockEditorStore);
const ids = rowCellIds();
const total = ids.length;
registry.batch(() => {
ids.forEach((id, index) => {
const styles = be.getBlockAttributes(id)?.styles ?? {};
const isFirst = index === 0;
const isLast = index === total - 1;
updateBlockAttributes(id, {
styles: {
...styles,
borderRadius: {
topLeft: isFirst ? newRadius.topLeft : "",
topRight: isLast ? newRadius.topRight : "",
bottomRight: isLast ? newRadius.bottomRight : "",
bottomLeft: isFirst ? newRadius.bottomLeft : "",
},
},
});
});
});
};
const rowRadius = proExtensionActive ? readRowRadius() : EMPTY_CORNERS;
const rowBorderContext: RowBorderContext = {
rowBorderControlProps: {
label: __("Row Border", "tableberg"),
value: border,
hasValue: () =>
!!border.top || !!border.right || !!border.bottom || !!border.left,
onChange: (newBorder: Border) =>
setAttributes({ border: newBorder }),
onDeselect: () => setAttributes({ border: undefined }),
},
rowRadiusControlProps: {
label: __("Row Border Radius", "tableberg"),
value: rowRadius,
hasValue: () =>
!!rowRadius.topLeft ||
!!rowRadius.topRight ||
!!rowRadius.bottomRight ||
!!rowRadius.bottomLeft,
onChange: (newRadius: Corners) => applyRowRadius(newRadius),
onDeselect: () => applyRowRadius(EMPTY_CORNERS),
},
};
return (
<>
{isSelected && (
<>
setAttributes({
height: height || undefined,
})
}
/>
{/*
* The colour control renders a ToolsPanel item, so it
* only shows up inside a ToolsPanel. The editor's own
* "Color" group is one; a plain PanelBody is not.
*/}
{ProRowBackgroundContent ? (
ProRowBackgroundContent(rowBackgroundContext)
) : (
<>
null}
onDeselect={() => null}
/>
null}
onDeselect={() => null}
/>
>
)}
{/*
* BorderControl renders a ToolsPanelItem too, so it
* only shows up inside a ToolsPanel. WordPress's own
* "Border" group (Styles tab) is one, same as "color".
*/}
{ProRowBorderContent ? (
ProRowBorderContent(rowBorderContext)
) : (
<>
false}
onChange={() => null}
onDeselect={() => null}
/>
false}
onChange={() => null}
onDeselect={() => null}
/>
>
)}
>
)}
>
);
}
export function registerNativeRowBlock() {
registerBlockType(metadata.name, {
...(metadata as any),
attributes: withMoveLock(metadata.attributes as any),
icon: blockIcon,
edit: RowEdit,
save: () => ,
});
}