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 / list / element.tsx

element.tsx in Tableberg – Simple Gutenberg Table Block 1.2.0, at src/blocks/list/element.tsx

624 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useRef, useCallback, useState } from "react";
2 import { RichText, BlockControls } from "@wordpress/block-editor";
3 import { __ } from "@wordpress/i18n";
4 import { mergeAttrsWithDefaultsAndApplyBindings } from "@tableberg/shared/utils/merge-attrs-with-defaults-and-apply-bindings";
5 import { ToolbarButton } from "@wordpress/components";
6 import { formatIndent, formatOutdent } from "@wordpress/icons";
7 import { ToolbarWithDropdown } from "@tableberg/components";
8
9 import { useTableStore } from "../../store";
10 import { useClickOutside } from "../../hooks/useClickOutside";
11 import { ElementRendererProps } from "../../elements";
12 import { BindableAttribute, ElementBindings } from "../../dynamic-data/types";
13 import { useDynamicDataBindings } from "../../dynamic-data/hooks/useDynamicData";
14 import { ListElementControls } from "./controls";
15 import { ElementDeleteButton } from "../../components/ElementDeleteButton";
16 import { ElementOptionsBlockControls } from "../../components/ElementOptionsButton";
17 import { useBlockCardUpdateShim } from "../../hooks/block-editor-compat";
18 import { isProAvailable } from "../../pro-status";
19 import {
20 elementAlignmentToJustifyContent,
21 ElementAlignment,
22 } from "../../alignment";
23
24 export interface ListItemAttributes {
25 content: string;
26 indentLevel: number;
27 }
28
29 interface ListItemNode {
30 item: ListItemAttributes;
31 index: number;
32 children: ListItemNode[];
33 }
34
35 function buildListTree(items: ListItemAttributes[]): ListItemNode[] {
36 const root: ListItemNode[] = [];
37 const stack: { node: ListItemNode; level: number }[] = [];
38
39 items.forEach((item, index) => {
40 const node: ListItemNode = { item, index, children: [] };
41 const level = item.indentLevel;
42
43 while (stack.length > 0 && stack[stack.length - 1].level >= level) {
44 stack.pop();
45 }
46
47 if (stack.length === 0) {
48 root.push(node);
49 } else {
50 stack[stack.length - 1].node.children.push(node);
51 }
52
53 stack.push({ node, level });
54 });
55
56 return root;
57 }
58
59 export interface ListElementAttributes {
60 align: ElementAlignment;
61 items: ListItemAttributes[];
62 listType: "basic" | "styled";
63 listStyle: "disc" | "circle" | "square" | "decimal" | "none";
64 /** Pro-owned: preserved by free's schema, but free never writes it. */
65 icon?: {
66 iconName: string;
67 svg?: {
68 viewBox: string;
69 path: string;
70 };
71 } | null;
72 styles: {
73 itemSpacing: string;
74 iconColor: string;
75 iconSize: string;
76 iconSpacing: string;
77 iconTopSpacing: string;
78 fontSize: string;
79 textColor: string;
80 linkColor: string;
81 backgroundColor: string;
82 };
83 }
84
85 export const listAttrDefaults: ListElementAttributes = {
86 align: "left",
87 items: [{ content: "", indentLevel: 0 }],
88 listType: "basic",
89 listStyle: "disc",
90 styles: {
91 itemSpacing: "0",
92 iconColor: "",
93 iconSize: "15px",
94 iconSpacing: "var(--wp--preset--spacing--20)",
95 iconTopSpacing: "0px",
96 // Inherited from the table unless the user sets one.
97 fontSize: "",
98 // Inherited from the row unless the user sets one.
99 textColor: "",
100 linkColor: "",
101 backgroundColor: "",
102 },
103 };
104
105 export interface ListElementType {
106 name: "list";
107 attributes: ListElementAttributes;
108 bindings?: ElementBindings;
109 }
110
111 export function ListElement({
112 attributes: initialAttributes,
113 bindings,
114 cellCoords,
115 elementIndex,
116 forcedListType,
117 allowListTypeSwitch = true,
118 // Injected by the pro plugin; null when pro is not installed.
119 ProListItemIcon = null,
120 ProListIconSettings = null,
121 }: ElementRendererProps<ListElementAttributes> & {
122 forcedListType?: "basic" | "styled";
123 allowListTypeSwitch?: boolean;
124 ProListItemIcon?: React.ReactNode;
125 ProListIconSettings?: React.ReactNode;
126 }) {
127 const wrapperRef = useRef<HTMLDivElement>(null);
128 const richTextRefs = useRef<(HTMLSpanElement | null)[]>([]);
129 const [focusedItemIndex, setFocusedItemIndex] = useState(0);
130
131 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
132 const isElementSelected = useTableStore(state => state.isElementSelected);
133 const setSelectedElement = useTableStore(state => state.setSelectedElement);
134 const clearSelectedElement = useTableStore(
135 state => state.clearSelectedElement
136 );
137 const updateBlockCard = useBlockCardUpdateShim();
138 const isPro = isProAvailable();
139
140 const isSelected = isElementSelected(cellCoords, elementIndex);
141
142 const { values: previewValues, isLoading: isDynamicLoading } =
143 useDynamicDataBindings(bindings);
144
145 useClickOutside({
146 ref: wrapperRef,
147 onClickOutside: () => {
148 if (isSelected) {
149 clearSelectedElement();
150 }
151 },
152 });
153
154 const listAttributes = mergeAttrsWithDefaultsAndApplyBindings(
155 initialAttributes,
156 listAttrDefaults,
157 bindings,
158 previewValues,
159 __("(No data)", "tableberg")
160 );
161
162 const updateCellElement = useTableStore(state => state.updateCellElement);
163
164 // A styled list without a licence is shown as a basic one, but only for
165 // display — the saved listType is left alone, the same way ListRenderer
166 // downgrades at render time without touching the data. Rewriting it here
167 // would quietly discard the styling of a list made while pro was active.
168 const effectiveListAttributes = forcedListType
169 ? { ...listAttributes, listType: forcedListType }
170 : !isPro && listAttributes.listType === "styled"
171 ? { ...listAttributes, listType: "basic" as const }
172 : listAttributes;
173
174 const itemRefs = richTextRefs.current;
175
176 const updateItems = useCallback(
177 (newItems: ListItemAttributes[], focusIndex?: number) => {
178 updateCellElement(cellCoords, elementIndex, {
179 attributes: { items: newItems },
180 });
181 if (focusIndex !== undefined) {
182 setTimeout(() => {
183 const element = itemRefs[focusIndex];
184 if (element) {
185 element.focus();
186 }
187 }, 0);
188 }
189 },
190 [cellCoords, elementIndex, updateCellElement, itemRefs]
191 );
192
193 const addItem = useCallback(
194 (insertAtIndex?: number, inheritIndentFrom?: number) => {
195 const indentLevel =
196 inheritIndentFrom !== undefined
197 ? (effectiveListAttributes.items[inheritIndentFrom]
198 ?.indentLevel ?? 0)
199 : 0;
200 const insertIdx =
201 insertAtIndex ?? effectiveListAttributes.items.length;
202 const newItems = [
203 ...effectiveListAttributes.items.slice(0, insertIdx),
204 { content: "", indentLevel },
205 ...effectiveListAttributes.items.slice(insertIdx),
206 ];
207 updateItems(newItems, insertIdx);
208 },
209 [effectiveListAttributes.items, updateItems]
210 );
211
212 const removeItem = useCallback(
213 (index: number) => {
214 if (effectiveListAttributes.items.length <= 1) {
215 updateItems([{ content: "", indentLevel: 0 }], 0);
216 } else {
217 const newItems = effectiveListAttributes.items.filter(
218 (_, i) => i !== index
219 );
220 const focusIdx = index > 0 ? index - 1 : 0;
221 updateItems(newItems, focusIdx);
222 }
223 },
224 [effectiveListAttributes.items, updateItems]
225 );
226
227 const canIndent = useCallback(
228 (index: number) => {
229 if (index === 0) return false;
230 const currentIndent =
231 effectiveListAttributes.items[index]?.indentLevel ?? 0;
232 const prevIndent =
233 effectiveListAttributes.items[index - 1]?.indentLevel ?? 0;
234 return currentIndent <= prevIndent;
235 },
236 [effectiveListAttributes.items]
237 );
238
239 const canOutdent = useCallback(
240 (index: number) => {
241 const currentIndent =
242 effectiveListAttributes.items[index]?.indentLevel ?? 0;
243 return currentIndent > 0;
244 },
245 [effectiveListAttributes.items]
246 );
247
248 const indentItem = useCallback(
249 (index: number) => {
250 if (!canIndent(index)) return;
251 const newItems = [...effectiveListAttributes.items];
252 const currentIndent = newItems[index]?.indentLevel ?? 0;
253 newItems[index] = {
254 ...newItems[index],
255 indentLevel: currentIndent + 1,
256 };
257 updateCellElement(cellCoords, elementIndex, {
258 attributes: { items: newItems },
259 });
260 setTimeout(() => {
261 itemRefs[index]?.focus();
262 }, 0);
263 },
264 [
265 effectiveListAttributes.items,
266 cellCoords,
267 elementIndex,
268 updateCellElement,
269 canIndent,
270 itemRefs,
271 ]
272 );
273
274 const outdentItem = useCallback(
275 (index: number) => {
276 if (!canOutdent(index)) return;
277 const newItems = [...effectiveListAttributes.items];
278 const currentIndent = newItems[index]?.indentLevel ?? 0;
279 newItems[index] = {
280 ...newItems[index],
281 indentLevel: currentIndent - 1,
282 };
283 updateCellElement(cellCoords, elementIndex, {
284 attributes: { items: newItems },
285 });
286 setTimeout(() => {
287 itemRefs[index]?.focus();
288 }, 0);
289 },
290 [
291 effectiveListAttributes.items,
292 cellCoords,
293 elementIndex,
294 updateCellElement,
295 canOutdent,
296 itemRefs,
297 ]
298 );
299
300 const updateItemContent = useCallback(
301 (index: number, content: string) => {
302 const newItems = [...effectiveListAttributes.items];
303 newItems[index] = { ...newItems[index], content };
304 updateCellElement(cellCoords, elementIndex, {
305 attributes: { items: newItems },
306 });
307 },
308 [
309 effectiveListAttributes.items,
310 cellCoords,
311 elementIndex,
312 updateCellElement,
313 ]
314 );
315
316 const handleItemKeyDown = useCallback(
317 (index: number, event: React.KeyboardEvent<"span">) => {
318 if (event.key === "Enter") {
319 event.preventDefault();
320 addItem(index + 1, index);
321 }
322 if (event.key === "Tab") {
323 event.preventDefault();
324 event.stopPropagation();
325 if (event.shiftKey) {
326 outdentItem(index);
327 } else {
328 indentItem(index);
329 }
330 }
331 if (
332 event.key === "Backspace" &&
333 effectiveListAttributes.items[index].content === ""
334 ) {
335 event.preventDefault();
336 if (canOutdent(index)) {
337 outdentItem(index);
338 } else {
339 removeItem(index);
340 }
341 }
342 },
343 [
344 addItem,
345 indentItem,
346 outdentItem,
347 removeItem,
348 canOutdent,
349 effectiveListAttributes.items,
350 ]
351 );
352
353 const styledListStyle: React.CSSProperties = {
354 listStyleType: "none",
355 margin: 0,
356 };
357
358 const iconSize = effectiveListAttributes.styles.iconSize || "15px";
359 const itemFontSize = effectiveListAttributes.styles.fontSize || "1em";
360 const iconTopSpacing =
361 effectiveListAttributes.styles.iconTopSpacing || "0px";
362 // Auto-centers the icon against the first line of (possibly wrapped)
363 // item text; iconTopSpacing is a manual nudge added on top for cases
364 // the auto value doesn't quite cover (custom fonts/line-heights).
365 const firstLineIconOffset = `calc(max(0px, (1.5 * ${itemFontSize} - ${iconSize}) / 2) + ${iconTopSpacing})`;
366
367 const getItemStyle = (): React.CSSProperties => ({
368 display: "flex",
369 alignItems: "flex-start",
370 gap: effectiveListAttributes.styles.iconSpacing,
371 fontSize: effectiveListAttributes.styles.fontSize || undefined,
372 lineHeight: 1.5,
373 padding: "2px 0",
374 margin: effectiveListAttributes.styles.itemSpacing
375 ? `0 0 ${effectiveListAttributes.styles.itemSpacing} 0`
376 : "0 0 2px 0",
377 });
378
379 const iconStyle: React.CSSProperties = {
380 color: effectiveListAttributes.styles.iconColor || undefined,
381 width: iconSize,
382 height: iconSize,
383 minWidth: iconSize,
384 flex: "0 0 auto",
385 display: "inline-flex",
386 alignItems: "center",
387 justifyContent: "center",
388 lineHeight: 0,
389 marginTop: firstLineIconOffset,
390 };
391
392 const decimalMarkerStyle: React.CSSProperties = {
393 color: effectiveListAttributes.styles.iconColor || undefined,
394 minWidth: "20px",
395 flex: "0 0 auto",
396 lineHeight: 1.5,
397 };
398
399 const textStyle: Record<string, string | number | undefined> = {
400 "fontSize": effectiveListAttributes.styles.fontSize || undefined,
401 "color": effectiveListAttributes.styles.textColor || undefined,
402 "--tableberg-list-link-color":
403 effectiveListAttributes.styles.linkColor || undefined,
404 "backgroundColor":
405 effectiveListAttributes.styles.backgroundColor || undefined,
406 "padding": effectiveListAttributes.styles.backgroundColor
407 ? "2px 8px"
408 : undefined,
409 "borderRadius": effectiveListAttributes.styles.backgroundColor
410 ? "4px"
411 : undefined,
412 };
413
414 // Icon markers are a pro feature: pro hands the <svg> down and free
415 // draws nothing without it. The positioning wrapper below stays here
416 // because the decimal marker shares its styles. Mirrors the PHP side,
417 // where free asks through `tableberg/render_list_item_icon`.
418
419 const renderListItemContent = (
420 content: string,
421 index: number,
422 additionalStyle?: React.CSSProperties
423 ) => {
424 const combinedStyle = { ...textStyle, ...additionalStyle };
425
426 if (sortPreviewMode) {
427 return (
428 <RichText.Content
429 tagName="span"
430 style={combinedStyle}
431 value={content}
432 />
433 );
434 }
435
436 return (
437 <RichText
438 ref={el => {
439 // @ts-expect-error
440 itemRefs[index] = el;
441 }}
442 tagName="span"
443 style={combinedStyle}
444 placeholder={__("List item", "tableberg")}
445 value={content}
446 onChange={newContent => updateItemContent(index, newContent)}
447 onFocus={() => setFocusedItemIndex(index)}
448 onKeyDownCapture={event => handleItemKeyDown(index, event)}
449 />
450 );
451 };
452
453 const ListTag =
454 effectiveListAttributes.listStyle === "decimal" ? "ol" : "ul";
455
456 const renderBasicListItems = (nodes: ListItemNode[]): React.ReactNode => {
457 return nodes.map(node => (
458 <li key={node.index}>
459 {renderListItemContent(node.item.content, node.index)}
460 {node.children.length > 0 && (
461 <ListTag style={{ margin: 0 }}>
462 {renderBasicListItems(node.children)}
463 </ListTag>
464 )}
465 </li>
466 ));
467 };
468
469 const renderStyledListItems = (nodes: ListItemNode[]): React.ReactNode => {
470 return nodes.map(node => (
471 <li key={node.index}>
472 <div style={getItemStyle()}>
473 {effectiveListAttributes.listStyle !== "decimal" &&
474 ProListItemIcon && (
475 <span style={iconStyle}>{ProListItemIcon}</span>
476 )}
477 {effectiveListAttributes.listStyle === "decimal" && (
478 <span style={decimalMarkerStyle}>
479 {node.index + 1}.
480 </span>
481 )}
482 {renderListItemContent(node.item.content, node.index, {
483 flex: 1,
484 })}
485 </div>
486 {node.children.length > 0 && (
487 <ListTag style={{ ...styledListStyle, marginTop: "4px" }}>
488 {renderStyledListItems(node.children)}
489 </ListTag>
490 )}
491 </li>
492 ));
493 };
494
495 const listTree = buildListTree(effectiveListAttributes.items);
496 const wrapperStyle: React.CSSProperties = {
497 display: "flex",
498 justifyContent: elementAlignmentToJustifyContent(
499 effectiveListAttributes.align
500 ),
501 };
502
503 return (
504 <>
505 {isSelected && (
506 <>
507 <BlockControls>
508 <ToolbarWithDropdown
509 title={__("Align list", "tableberg")}
510 value={effectiveListAttributes.align}
511 onChange={(newAlign?: string) => {
512 if (
513 newAlign !== "left" &&
514 newAlign !== "center" &&
515 newAlign !== "right"
516 ) {
517 return;
518 }
519
520 updateCellElement(cellCoords, elementIndex, {
521 attributes: {
522 align: newAlign,
523 },
524 });
525 }}
526 controlset="alignment"
527 />
528 <ToolbarButton
529 icon={formatOutdent}
530 label={__("Outdent", "tableberg")}
531 onClick={() => outdentItem(focusedItemIndex)}
532 disabled={!canOutdent(focusedItemIndex)}
533 />
534 <ToolbarButton
535 icon={formatIndent}
536 label={__("Indent", "tableberg")}
537 onClick={() => indentItem(focusedItemIndex)}
538 disabled={!canIndent(focusedItemIndex)}
539 />
540 <ElementDeleteButton
541 cellCoords={cellCoords}
542 elementIndex={elementIndex}
543 />
544 </BlockControls>
545 <ElementOptionsBlockControls
546 cellCoords={cellCoords}
547 elementIndex={elementIndex}
548 />
549 <ListElementControls
550 attributes={effectiveListAttributes}
551 bindings={bindings}
552 cellCoords={cellCoords}
553 elementIndex={elementIndex}
554 allowListTypeSwitch={allowListTypeSwitch}
555 ProListIconSettings={ProListIconSettings}
556 />
557 </>
558 )}
559 <div
560 ref={wrapperRef}
561 className={`tableberg-list${
562 effectiveListAttributes.styles.linkColor
563 ? " tableberg-has-link-color"
564 : ""
565 }`}
566 style={wrapperStyle}
567 onClick={e => {
568 e.stopPropagation();
569 if (!sortPreviewMode) {
570 setSelectedElement(cellCoords, elementIndex);
571 if (wrapperRef.current) {
572 updateBlockCard(
573 wrapperRef.current,
574 "List",
575 "A list element within a Tableberg cell"
576 );
577 }
578 }
579 }}
580 >
581 {isDynamicLoading ? (
582 <p style={{ margin: 0 }}>{__("Loading...", "tableberg")}</p>
583 ) : effectiveListAttributes.listType === "basic" ? (
584 <ListTag
585 style={{
586 margin: 0,
587 paddingLeft: "20px",
588 }}
589 >
590 {renderBasicListItems(listTree)}
591 </ListTag>
592 ) : (
593 <ListTag style={{ ...styledListStyle, paddingLeft: 0 }}>
594 {renderStyledListItems(listTree)}
595 </ListTag>
596 )}
597 </div>
598 </>
599 );
600 }
601
602 export const listBindableAttributes: BindableAttribute[] = [
603 {
604 path: "styles.iconColor",
605 label: __("Icon Color", "tableberg"),
606 },
607 {
608 path: "styles.textColor",
609 label: __("Text Color", "tableberg"),
610 },
611 {
612 path: "styles.linkColor",
613 label: __("Link Color", "tableberg"),
614 },
615 {
616 path: "styles.backgroundColor",
617 label: __("Background Color", "tableberg"),
618 },
619 {
620 path: "styles.fontSize",
621 label: __("Font Size", "tableberg"),
622 },
623 ];
624