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

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

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