import { CSSProperties, useRef } from "react"; import { RichText, InspectorControls, BlockControls, } from "@wordpress/block-editor"; import { getBlockContent } from "@wordpress/blocks"; import { ToolbarWithDropdown } from "@tableberg/components"; import { mergeAttrsWithDefaultsAndApplyBindings } from "@tableberg/shared/utils/merge-attrs-with-defaults-and-apply-bindings"; import { __ } from "@wordpress/i18n"; import { getCellChildAutoCompleter } from "../../CellChildAutoCompleter"; import { CellElement, CellKey } from "../../attributes"; import { useTableStore } from "../../store"; import { useClickOutside } from "../../hooks/useClickOutside"; import { ElementRendererProps, createElement } from "../../elements"; import { DynamicDataPanel } from "../../components/DynamicDataPanel"; import { useDynamicDataBindings } from "../../dynamic-data/hooks/useDynamicData"; import { BindableAttribute, ElementBindings } from "../../dynamic-data/types"; import { HighlightedText } from "../../components/HighlightedText"; import { ElementDeleteButton } from "../../components/ElementDeleteButton"; import { ElementOptionsBlockControls } from "../../components/ElementOptionsButton"; import { useBlockCardUpdateShim } from "../../hooks/block-editor-compat"; import { elementAlignmentToJustifyContent, ElementAlignment, } from "../../alignment"; import { TextElementControls } from "./controls"; import { isProAvailable } from "../../pro-status"; interface FourSides { top: string; right: string; bottom: string; left: string; } export interface TextElementAttributes { content: string; align: ElementAlignment; styles: { textColor: string; linkColor: string; backgroundColor: string; fontSize: string; padding: FourSides; margin: FourSides; }; } const emptySides: FourSides = { top: "", right: "", bottom: "", left: "" }; export const textAttributeDefaults: TextElementAttributes = { content: "", align: "left", styles: { // No colour of its own: the text takes the row's (and through it the // theme's), the way the core Table block does. textColor: "", linkColor: "", backgroundColor: "", // No size of its own: the text takes the table's (and through it // the theme's) font size, the way the core Table block does. fontSize: "", padding: { ...emptySides }, margin: { ...emptySides }, }, }; // Build a CSS shorthand from a 4-side object; returns undefined when all sides // are empty so the property is simply not set. export function sidesToShorthand(sides?: FourSides): string | undefined { if (!sides) { return undefined; } const { top, right, bottom, left } = sides; if (!top && !right && !bottom && !left) { return undefined; } return `${top || "0"} ${right || "0"} ${bottom || "0"} ${left || "0"}`; } export interface TextElementType { name: "text"; attributes: TextElementAttributes; bindings?: ElementBindings; } // When a cell is empty and content is pasted, the block editor hands the // resulting blocks to onReplace. Since this RichText lives outside a real // block context there is no block to replace, so we flatten the pasted blocks // to plain text and drop it into the cell instead of losing the paste. function pastedBlocksToPlainText(blocks: any[]): string { return blocks .map(block => { const html = typeof block?.attributes?.content === "string" ? block.attributes.content : getBlockContent(block) || ""; const el = document.createElement("div"); el.innerHTML = html; return (el.textContent || "").trim(); }) .filter(Boolean) .join("\n"); } export function TextElement({ attributes, bindings, cellCoords, elementIndex, }: ElementRendererProps) { const wrapperRef = useRef(null); const sortPreviewMode = useTableStore(state => state.sortPreviewMode); const setSelectedElement = useTableStore(state => state.setSelectedElement); const clearSelectedElement = useTableStore( state => state.clearSelectedElement ); const updateCellElement = useTableStore(state => state.updateCellElement); const updateBlockCard = useBlockCardUpdateShim(); const isSelected = useTableStore(state => state.isElementSelected(cellCoords, elementIndex) ); useClickOutside({ ref: wrapperRef, onClickOutside: () => { if (isSelected) { clearSelectedElement(); } }, }); const { values: previewValues, isLoading: isDynamicLoading } = useDynamicDataBindings(bindings); const mergedAttrs = mergeAttrsWithDefaultsAndApplyBindings( attributes, textAttributeDefaults, bindings, previewValues, __("(No data)", "tableberg") ); const dynamicBindingEnabled = !!bindings?.content; const { content, align, styles } = mergedAttrs; const wrapperStyle: CSSProperties = { display: "flex", justifyContent: elementAlignmentToJustifyContent(align), // text-align is inherited, so wrapped lines inside the

align too. textAlign: align, }; return ( <> {isSelected && ( <> { if ( newAlign !== "left" && newAlign !== "center" && newAlign !== "right" ) { return; } updateCellElement(cellCoords, elementIndex, { attributes: { align: newAlign, }, }); }} controlset="alignment" /> )}

{ e.stopPropagation(); if (!sortPreviewMode) { setSelectedElement(cellCoords, elementIndex); if (wrapperRef.current) { updateBlockCard( wrapperRef.current, "Text", "A text element within a Tableberg cell" ); } } }} >
); } function TextElementContent({ content, cellCoords, elementIndex, dynamicBindingEnabled, isDynamicLoading, textColor, linkColor, backgroundColor, fontSize, padding, margin, }: { content: string; cellCoords: CellKey; elementIndex: number; dynamicBindingEnabled: boolean; isDynamicLoading: boolean; textColor: string; linkColor: string; backgroundColor: string; fontSize: string; padding: FourSides; margin: FourSides; }) { const cellChildAutoCompleter = getCellChildAutoCompleter(); const sortPreviewMode = useTableStore(state => state.sortPreviewMode); const updateCellElement = useTableStore(state => state.updateCellElement); const replaceCellElement = useTableStore(state => state.replaceCellElement); const searchTerm = useTableStore(state => state.searchTerm); const searchConfig = useTableStore(state => state.table.search); const isPro = isProAvailable(); const shouldHighlight = isPro && searchConfig?.enabled && searchConfig?.highlightColor && searchTerm.trim(); const paddingValue = sidesToShorthand(padding); const marginValue = sidesToShorthand(margin); const textStyle: Record = { "margin": marginValue ?? 0, "padding": paddingValue, "color": textColor || undefined, "fontSize": fontSize || undefined, "backgroundColor": backgroundColor || undefined, "--tableberg-text-link-color": linkColor || undefined, }; const richTextStyle: Record = { "margin": marginValue, "padding": paddingValue, "color": textColor || undefined, "fontSize": fontSize || undefined, "backgroundColor": backgroundColor || undefined, "--tableberg-text-link-color": linkColor || undefined, }; const elementClassName = `tableberg-text-element${ linkColor ? " tableberg-has-link-color" : "" }${dynamicBindingEnabled ? " tableberg-dynamic-content" : ""}`; if (isDynamicLoading) { return (

{__("Loading...", "tableberg")}

); } if (shouldHighlight) { return (

); } if (sortPreviewMode || dynamicBindingEnabled) { return ( ); } return ( updateCellElement(cellCoords, elementIndex, { attributes: { content }, }) } autocompleters={[cellChildAutoCompleter]} style={richTextStyle} onReplace={(replacement: any[]) => { const first = replacement?.[0]; // The "/" autocompleter sends a single element-name string. if (typeof first === "string") { const newElement = createElement( first as CellElement["name"] ); if (newElement) { replaceCellElement( cellCoords, elementIndex, newElement ); } return; } // Otherwise this is pasted block content: flatten to text. const text = pastedBlocksToPlainText(replacement); if (text) { updateCellElement(cellCoords, elementIndex, { attributes: { content: text }, }); } }} /> ); } export const textBindableAttributes: BindableAttribute[] = [ { path: "content", label: __("Content", "tableberg"), }, { path: "styles.textColor", label: __("Text Color", "tableberg"), }, { path: "styles.linkColor", label: __("Link Color", "tableberg"), }, { path: "styles.backgroundColor", label: __("Background Color", "tableberg"), }, { path: "styles.fontSize", label: __("Font Size", "tableberg"), }, ];