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

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

509 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { 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
13 import metadata from "./block.json";
14 import {
15 PanelBody,
16 ToolbarDropdownMenu,
17 __experimentalUnitControl as UnitControl,
18 } from "@wordpress/components";
19 import {
20 tableRowAfter,
21 tableRowBefore,
22 tableRowDelete,
23 table as tableIcon,
24 } from "@wordpress/icons";
25 import { __ } from "@wordpress/i18n";
26 import {
27 ColorControl,
28 BorderControl,
29 BorderRadiusControl,
30 } from "@tableberg/components";
31 import blockIcon from "@tableberg/shared/icons/tableberg";
32 import LockedControl from "../../components/LockedControl";
33 import { UpsellEnhancedModal } from "../../components/UpsellModal";
34 import { isProAvailable } from "../../pro-status";
35 import { ColorControlProps } from "../../hooks";
36 import { Border, Corners } from "../../attributes";
37 import { deleteRow, insertRow } from "../table/table-ops";
38 import { withMoveLock } from "../move-lock";
39
40 const EMPTY_BORDER: Border = { top: "", right: "", bottom: "", left: "" };
41
42 const EMPTY_CORNERS: Corners = {
43 topLeft: "",
44 topRight: "",
45 bottomRight: "",
46 bottomLeft: "",
47 };
48
49 export interface RowBlockAttrs {
50 height?: string;
51 // Pro-owned: declared in the schema for preservation while pro is inactive.
52 // Free never writes it.
53 backgroundColor?: string;
54 textColor?: string;
55 border?: Border;
56 }
57
58 /** Everything pro's row colour controls need. */
59 export interface RowBackgroundContext {
60 rowBackgroundColorControl: ColorControlProps;
61 rowTextColorControl: ColorControlProps;
62 }
63
64 /** Where a row sits, for entries that only make sense elsewhere. */
65 export interface RowGridPosition {
66 rowIndex: number;
67 rowCount: number;
68 /** A merged cell ties rows together, so moves are off the table. */
69 hasMergedCells: boolean;
70 }
71
72 /** Toolbar entries pro adds to the row's "Edit row" menu. */
73 export interface RowToolbarControl {
74 icon: unknown;
75 title: string;
76 /** Left out when it returns false, e.g. "move up" on the first row. */
77 isAvailable?: (position: RowGridPosition) => boolean;
78 onClick: (context: {
79 registry: any;
80 tableClientId: string;
81 rowIndex: number;
82 }) => void;
83 }
84
85 /**
86 * The row's own "Edit row" menu: the same dropdown the cell has, with only
87 * the entries that act on a row. Rows carry no mover of their own (moving is
88 * a pro feature, see the block's lock), so this is where a row is added,
89 * removed or moved from.
90 */
91 function RowTableToolbar({
92 clientId,
93 // Injected by the pro plugin; empty when pro is not installed.
94 proToolbarControls = [],
95 }: {
96 clientId: string;
97 proToolbarControls?: RowToolbarControl[];
98 }) {
99 const registry = useRegistry() as any;
100 const isPro = isProAvailable();
101 const [showUpsell, setShowUpsell] = useState<
102 "duplicate-row-col" | null
103 >(null);
104
105 const position = useSelect(
106 select => {
107 const be = select(blockEditorStore) as any;
108 const tableClientId = be.getBlockRootClientId(clientId);
109
110 if (!tableClientId) {
111 return null;
112 }
113
114 const rows = (be.getBlock(tableClientId)?.innerBlocks ?? []).filter(
115 (b: any) => b.name === "tableberg/row"
116 );
117
118 return {
119 tableClientId,
120 rowIndex: be.getBlockIndex(clientId),
121 rowCount: rows.length,
122 hasMergedCells: rows.some((rowBlock: any) =>
123 (rowBlock.innerBlocks ?? []).some(
124 (cellBlock: any) =>
125 (cellBlock.attributes?.span?.rowSpan ?? 1) > 1 ||
126 (cellBlock.attributes?.span?.colSpan ?? 1) > 1
127 )
128 ),
129 };
130 },
131 [clientId]
132 );
133
134 if (!position) {
135 return null;
136 }
137
138 const { tableClientId, rowIndex } = position;
139
140 const controls = [
141 {
142 icon: tableRowBefore,
143 title: __("Insert row above", "tableberg"),
144 onClick: () => insertRow(registry, tableClientId, rowIndex),
145 },
146 {
147 icon: tableRowAfter,
148 title: __("Insert row below", "tableberg"),
149 onClick: () => insertRow(registry, tableClientId, rowIndex + 1),
150 },
151 {
152 icon: tableRowDelete,
153 title: __("Delete row", "tableberg"),
154 onClick: () => deleteRow(registry, tableClientId, rowIndex),
155 },
156 // Duplicating and moving a row are pro features: pro supplies these
157 // entries and free only resolves the row's position for them.
158 ...proToolbarControls
159 .filter(control =>
160 control.isAvailable ? control.isAvailable(position) : true
161 )
162 .map(control => ({
163 icon: control.icon as any,
164 title: control.title,
165 onClick: () =>
166 control.onClick({ registry, tableClientId, rowIndex }),
167 })),
168 ...(proToolbarControls.length === 0 && !isPro
169 ? [
170 {
171 icon: tableRowAfter,
172 title: __("Duplicate row (Pro)", "tableberg"),
173 onClick: () => setShowUpsell("duplicate-row-col"),
174 },
175 ]
176 : []),
177 ];
178
179 return (
180 <>
181 <BlockControls group="block">
182 <ToolbarDropdownMenu
183 icon={tableIcon}
184 label={__("Edit row", "tableberg")}
185 controls={controls}
186 />
187 </BlockControls>
188 {showUpsell && (
189 <UpsellEnhancedModal
190 onClose={() => setShowUpsell(null)}
191 selected={showUpsell}
192 />
193 )}
194 </>
195 );
196 }
197
198 /** Everything pro's row border and corner controls need. */
199 export interface RowBorderContext {
200 rowBorderControlProps: {
201 label: string;
202 value: Border;
203 hasValue: () => boolean;
204 onChange: (newBorder: Border) => void;
205 onDeselect: () => void;
206 };
207 rowRadiusControlProps: {
208 label: string;
209 value: Corners;
210 hasValue: () => boolean;
211 onChange: (newRadius: Corners) => void;
212 onDeselect: () => void;
213 };
214 }
215
216 function RowEdit({
217 clientId,
218 attributes,
219 setAttributes,
220 isSelected,
221 // Injected by the pro plugin; undefined when pro is not installed. A
222 // render function rather than a plain node: claiming priority over a
223 // bulk-applied colour (even/odd, etc.) already sitting on this row's
224 // cells needs the block registry, which pro's editor.BlockEdit HOC
225 // can't reach from outside the tree.
226 ProRowBackgroundContent,
227 // Injected by the pro plugin; undefined when pro is not installed. A
228 // render function like the background one: the row's corners are the
229 // corners of its outermost cells, so rounding them needs the block
230 // registry, which pro's editor.BlockEdit HOC can't reach from outside
231 // the tree.
232 ProRowBorderContent,
233 // Injected by the pro plugin; empty when pro is not installed.
234 rowProToolbarControls,
235 }: BlockEditProps<RowBlockAttrs> & {
236 ProRowBackgroundContent?: (ctx: RowBackgroundContext) => ReactNode;
237 ProRowBorderContent?: (ctx: RowBorderContext) => ReactNode;
238 rowProToolbarControls?: RowToolbarControl[];
239 }) {
240 const registry = useRegistry() as any;
241 const { updateBlockAttributes } = useDispatch(blockEditorStore) as any;
242
243 // The schema keeps pro-owned values while pro is inactive, but the free
244 // editor must not preview behavior the frontend is currently gating out.
245 const proExtensionActive = Boolean(
246 ProRowBackgroundContent || ProRowBorderContent
247 );
248 const border = proExtensionActive
249 ? (attributes.border ?? EMPTY_BORDER)
250 : EMPTY_BORDER;
251 const blockProps = useBlockProps({
252 style: {
253 height: attributes.height || undefined,
254 backgroundColor: proExtensionActive
255 ? attributes.backgroundColor || undefined
256 : undefined,
257 // Inherited by everything in the row's cells that has no colour
258 // of its own, the way the core Column block does it.
259 color: proExtensionActive
260 ? attributes.textColor || undefined
261 : undefined,
262 borderTop: border.top || undefined,
263 borderRight: border.right || undefined,
264 borderBottom: border.bottom || undefined,
265 borderLeft: border.left || undefined,
266 },
267 });
268 const innerBlocksProps = useInnerBlocksProps(blockProps, {
269 allowedBlocks: ["tableberg/cell"],
270 renderAppender: false,
271 });
272
273 // A colour already bulk-applied to this row's own cells (even/odd, a
274 // column colour, or an older individual choice) would otherwise sit on
275 // top of the row's own background and hide it, since each cell paints
276 // its own background over the row's. Claiming a colour for the row
277 // clears it back off those cells so the row's colour actually shows.
278 const clearCellBackgrounds = () => {
279 const be = registry.select(blockEditorStore);
280 const cellIds: string[] = (be.getBlock(clientId)?.innerBlocks ?? [])
281 .filter((b: any) => b.name === "tableberg/cell")
282 .map((b: any) => b.clientId);
283
284 registry.batch(() => {
285 cellIds.forEach(id => {
286 const currentStyles = be.getBlockAttributes(id)?.styles ?? {};
287 const { backgroundColor: _legacy, ...styles } = currentStyles;
288 updateBlockAttributes(id, {
289 backgroundColor: null,
290 styles,
291 });
292 });
293 });
294 };
295
296 const rowBackgroundContext: RowBackgroundContext = {
297 rowBackgroundColorControl: {
298 label: __("Row Background Color", "tableberg"),
299 value: attributes.backgroundColor ?? "",
300 onChange: (value: string) => {
301 setAttributes({ backgroundColor: value || undefined });
302 if (value) {
303 clearCellBackgrounds();
304 }
305 },
306 onDeselect: () => setAttributes({ backgroundColor: undefined }),
307 },
308 rowTextColorControl: {
309 label: __("Row Text Color", "tableberg"),
310 value: attributes.textColor ?? "",
311 onChange: (value: string) =>
312 setAttributes({ textColor: value || undefined }),
313 onDeselect: () => setAttributes({ textColor: undefined }),
314 },
315 };
316
317 // A row has no corners of its own to round: a `border-radius` on a
318 // `<tr>` does nothing under border-collapse. What reads as a rounded row
319 // is its first cell rounded on the left and its last cell on the right,
320 // so the control resolves and writes those cells' own corners.
321 const rowCellIds = (): string[] =>
322 (registry.select(blockEditorStore).getBlock(clientId)?.innerBlocks ??
323 [])
324 .filter((b: any) => b.name === "tableberg/cell")
325 .map((b: any) => b.clientId);
326
327 const readRowRadius = (): Corners => {
328 const be = registry.select(blockEditorStore);
329 const ids = rowCellIds();
330
331 if (ids.length === 0) {
332 return EMPTY_CORNERS;
333 }
334
335 const cornersOf = (id: string): Corners =>
336 (be.getBlockAttributes(id)?.styles?.borderRadius ??
337 EMPTY_CORNERS) as Corners;
338 const first = cornersOf(ids[0]);
339 const last = cornersOf(ids[ids.length - 1]);
340
341 return {
342 topLeft: first.topLeft ?? "",
343 topRight: last.topRight ?? "",
344 bottomRight: last.bottomRight ?? "",
345 bottomLeft: first.bottomLeft ?? "",
346 };
347 };
348
349 const applyRowRadius = (newRadius: Corners) => {
350 const be = registry.select(blockEditorStore);
351 const ids = rowCellIds();
352 const total = ids.length;
353
354 registry.batch(() => {
355 ids.forEach((id, index) => {
356 const styles = be.getBlockAttributes(id)?.styles ?? {};
357 const isFirst = index === 0;
358 const isLast = index === total - 1;
359
360 updateBlockAttributes(id, {
361 styles: {
362 ...styles,
363 borderRadius: {
364 topLeft: isFirst ? newRadius.topLeft : "",
365 topRight: isLast ? newRadius.topRight : "",
366 bottomRight: isLast ? newRadius.bottomRight : "",
367 bottomLeft: isFirst ? newRadius.bottomLeft : "",
368 },
369 },
370 });
371 });
372 });
373 };
374
375 const rowRadius = proExtensionActive ? readRowRadius() : EMPTY_CORNERS;
376
377 const rowBorderContext: RowBorderContext = {
378 rowBorderControlProps: {
379 label: __("Row Border", "tableberg"),
380 value: border,
381 hasValue: () =>
382 !!border.top || !!border.right || !!border.bottom || !!border.left,
383 onChange: (newBorder: Border) =>
384 setAttributes({ border: newBorder }),
385 onDeselect: () => setAttributes({ border: undefined }),
386 },
387 rowRadiusControlProps: {
388 label: __("Row Border Radius", "tableberg"),
389 value: rowRadius,
390 hasValue: () =>
391 !!rowRadius.topLeft ||
392 !!rowRadius.topRight ||
393 !!rowRadius.bottomRight ||
394 !!rowRadius.bottomLeft,
395 onChange: (newRadius: Corners) => applyRowRadius(newRadius),
396 onDeselect: () => applyRowRadius(EMPTY_CORNERS),
397 },
398 };
399
400 return (
401 <>
402 {isSelected && (
403 <>
404 <RowTableToolbar
405 clientId={clientId}
406 proToolbarControls={rowProToolbarControls}
407 />
408 <InspectorControls>
409 <PanelBody title={__("Row Settings", "tableberg")}>
410 <UnitControl
411 label={__("Row Height", "tableberg")}
412 value={attributes.height ?? ""}
413 onChange={(height?: string) =>
414 setAttributes({
415 height: height || undefined,
416 })
417 }
418 />
419 </PanelBody>
420 </InspectorControls>
421 {/*
422 * The colour control renders a ToolsPanel item, so it
423 * only shows up inside a ToolsPanel. The editor's own
424 * "Color" group is one; a plain PanelBody is not.
425 */}
426 <InspectorControls group="color">
427 {ProRowBackgroundContent ? (
428 ProRowBackgroundContent(rowBackgroundContext)
429 ) : (
430 <>
431 <LockedControl isEnhanced selected="row-bg">
432 <ColorControl
433 label={__(
434 "Row Background Color",
435 "tableberg"
436 )}
437 value=""
438 onChange={() => null}
439 onDeselect={() => null}
440 />
441 </LockedControl>
442 <LockedControl isEnhanced selected="row-text">
443 <ColorControl
444 label={__(
445 "Row Text Color",
446 "tableberg"
447 )}
448 value=""
449 onChange={() => null}
450 onDeselect={() => null}
451 />
452 </LockedControl>
453 </>
454 )}
455 </InspectorControls>
456 {/*
457 * BorderControl renders a ToolsPanelItem too, so it
458 * only shows up inside a ToolsPanel. WordPress's own
459 * "Border" group (Styles tab) is one, same as "color".
460 */}
461 <InspectorControls group="border">
462 {ProRowBorderContent ? (
463 ProRowBorderContent(rowBorderContext)
464 ) : (
465 <>
466 <LockedControl isEnhanced selected="row-border">
467 <BorderControl
468 label={__("Row Border", "tableberg")}
469 value={EMPTY_BORDER}
470 hasValue={() => false}
471 onChange={() => null}
472 onDeselect={() => null}
473 />
474 </LockedControl>
475 <LockedControl
476 isEnhanced
477 selected="row-border-radius"
478 >
479 <BorderRadiusControl
480 label={__(
481 "Row Border Radius",
482 "tableberg"
483 )}
484 value={EMPTY_CORNERS}
485 hasValue={() => false}
486 onChange={() => null}
487 onDeselect={() => null}
488 />
489 </LockedControl>
490 </>
491 )}
492 </InspectorControls>
493 </>
494 )}
495 <tr {...innerBlocksProps} />
496 </>
497 );
498 }
499
500 export function registerNativeRowBlock() {
501 registerBlockType(metadata.name, {
502 ...(metadata as any),
503 attributes: withMoveLock(metadata.attributes as any),
504 icon: blockIcon,
505 edit: RowEdit,
506 save: () => <InnerBlocks.Content />,
507 });
508 }
509