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

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

394 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { CSSProperties, useRef } from "react";
2 import {
3 RichText,
4 InspectorControls,
5 BlockControls,
6 } from "@wordpress/block-editor";
7 import { getBlockContent } from "@wordpress/blocks";
8 import { ToolbarWithDropdown } from "@tableberg/components";
9 import { mergeAttrsWithDefaultsAndApplyBindings } from "@tableberg/shared/utils/merge-attrs-with-defaults-and-apply-bindings";
10 import { __ } from "@wordpress/i18n";
11
12 import { getCellChildAutoCompleter } from "../../CellChildAutoCompleter";
13 import { CellElement, CellKey } from "../../attributes";
14 import { useTableStore } from "../../store";
15 import { useClickOutside } from "../../hooks/useClickOutside";
16 import { ElementRendererProps, createElement } from "../../elements";
17 import { DynamicDataPanel } from "../../components/DynamicDataPanel";
18 import { useDynamicDataBindings } from "../../dynamic-data/hooks/useDynamicData";
19 import { BindableAttribute, ElementBindings } from "../../dynamic-data/types";
20 import { HighlightedText } from "../../components/HighlightedText";
21 import { ElementDeleteButton } from "../../components/ElementDeleteButton";
22 import { ElementOptionsBlockControls } from "../../components/ElementOptionsButton";
23 import { useBlockCardUpdateShim } from "../../hooks/block-editor-compat";
24 import {
25 elementAlignmentToJustifyContent,
26 ElementAlignment,
27 } from "../../alignment";
28 import { TextElementControls } from "./controls";
29 import { isProAvailable } from "../../pro-status";
30
31 interface FourSides {
32 top: string;
33 right: string;
34 bottom: string;
35 left: string;
36 }
37
38 export interface TextElementAttributes {
39 content: string;
40 align: ElementAlignment;
41 styles: {
42 textColor: string;
43 linkColor: string;
44 backgroundColor: string;
45 fontSize: string;
46 padding: FourSides;
47 margin: FourSides;
48 };
49 }
50
51 const emptySides: FourSides = { top: "", right: "", bottom: "", left: "" };
52
53 export const textAttributeDefaults: TextElementAttributes = {
54 content: "",
55 align: "left",
56 styles: {
57 textColor: "#000000",
58 linkColor: "",
59 backgroundColor: "",
60 fontSize: "1.38rem",
61 padding: { ...emptySides },
62 margin: { ...emptySides },
63 },
64 };
65
66 // Build a CSS shorthand from a 4-side object; returns undefined when all sides
67 // are empty so the property is simply not set.
68 export function sidesToShorthand(sides?: FourSides): string | undefined {
69 if (!sides) {
70 return undefined;
71 }
72 const { top, right, bottom, left } = sides;
73 if (!top && !right && !bottom && !left) {
74 return undefined;
75 }
76 return `${top || "0"} ${right || "0"} ${bottom || "0"} ${left || "0"}`;
77 }
78
79 export interface TextElementType {
80 name: "text";
81 attributes: TextElementAttributes;
82 bindings?: ElementBindings;
83 }
84
85 // When a cell is empty and content is pasted, the block editor hands the
86 // resulting blocks to onReplace. Since this RichText lives outside a real
87 // block context there is no block to replace, so we flatten the pasted blocks
88 // to plain text and drop it into the cell instead of losing the paste.
89 function pastedBlocksToPlainText(blocks: any[]): string {
90 return blocks
91 .map(block => {
92 const html =
93 typeof block?.attributes?.content === "string"
94 ? block.attributes.content
95 : getBlockContent(block) || "";
96 const el = document.createElement("div");
97 el.innerHTML = html;
98 return (el.textContent || "").trim();
99 })
100 .filter(Boolean)
101 .join("\n");
102 }
103
104 export function TextElement({
105 attributes,
106 bindings,
107 cellCoords,
108 elementIndex,
109 }: ElementRendererProps<TextElementAttributes>) {
110 const wrapperRef = useRef<HTMLDivElement>(null);
111
112 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
113 const isElementSelected = useTableStore(state => state.isElementSelected);
114 const setSelectedElement = useTableStore(state => state.setSelectedElement);
115 const clearSelectedElement = useTableStore(
116 state => state.clearSelectedElement
117 );
118 const updateCellElement = useTableStore(state => state.updateCellElement);
119 const updateBlockCard = useBlockCardUpdateShim();
120
121 const isSelected = isElementSelected(cellCoords, elementIndex);
122
123 useClickOutside({
124 ref: wrapperRef,
125 onClickOutside: () => {
126 if (isSelected) {
127 clearSelectedElement();
128 }
129 },
130 });
131
132 const { values: previewValues, isLoading: isDynamicLoading } =
133 useDynamicDataBindings(bindings);
134
135 const mergedAttrs = mergeAttrsWithDefaultsAndApplyBindings(
136 attributes,
137 textAttributeDefaults,
138 bindings,
139 previewValues,
140 __("(No data)", "tableberg")
141 );
142
143 const dynamicBindingEnabled = !!bindings?.content;
144 const { content, align, styles } = mergedAttrs;
145
146 const wrapperStyle: CSSProperties = {
147 display: "flex",
148 justifyContent: elementAlignmentToJustifyContent(align),
149 // text-align is inherited, so wrapped lines inside the <p> align too.
150 textAlign: align,
151 };
152
153 return (
154 <>
155 {isSelected && (
156 <>
157 <BlockControls>
158 <ToolbarWithDropdown
159 title={__("Align text", "tableberg")}
160 value={align}
161 onChange={(newAlign?: string) => {
162 if (
163 newAlign !== "left" &&
164 newAlign !== "center" &&
165 newAlign !== "right"
166 ) {
167 return;
168 }
169
170 updateCellElement(cellCoords, elementIndex, {
171 attributes: {
172 align: newAlign,
173 },
174 });
175 }}
176 controlset="alignment"
177 />
178 <ElementDeleteButton
179 cellCoords={cellCoords}
180 elementIndex={elementIndex}
181 />
182 </BlockControls>
183 <ElementOptionsBlockControls
184 cellCoords={cellCoords}
185 elementIndex={elementIndex}
186 />
187 <TextElementControls
188 attributes={mergedAttrs}
189 bindings={bindings}
190 />
191 <InspectorControls>
192 <DynamicDataPanel
193 elementType="text"
194 bindings={bindings}
195 />
196 </InspectorControls>
197 </>
198 )}
199 <div
200 ref={wrapperRef}
201 style={wrapperStyle}
202 onClick={e => {
203 e.stopPropagation();
204 if (!sortPreviewMode) {
205 setSelectedElement(cellCoords, elementIndex);
206 if (wrapperRef.current) {
207 updateBlockCard(
208 wrapperRef.current,
209 "Text",
210 "A text element within a Tableberg cell"
211 );
212 }
213 }
214 }}
215 >
216 <TextElementContent
217 content={content}
218 cellCoords={cellCoords}
219 elementIndex={elementIndex}
220 dynamicBindingEnabled={dynamicBindingEnabled}
221 isDynamicLoading={isDynamicLoading}
222 textColor={styles.textColor}
223 linkColor={styles.linkColor}
224 backgroundColor={styles.backgroundColor}
225 fontSize={styles.fontSize}
226 padding={styles.padding}
227 margin={styles.margin}
228 />
229 </div>
230 </>
231 );
232 }
233
234 function TextElementContent({
235 content,
236 cellCoords,
237 elementIndex,
238 dynamicBindingEnabled,
239 isDynamicLoading,
240 textColor,
241 linkColor,
242 backgroundColor,
243 fontSize,
244 padding,
245 margin,
246 }: {
247 content: string;
248 cellCoords: CellKey;
249 elementIndex: number;
250 dynamicBindingEnabled: boolean;
251 isDynamicLoading: boolean;
252 textColor: string;
253 linkColor: string;
254 backgroundColor: string;
255 fontSize: string;
256 padding: FourSides;
257 margin: FourSides;
258 }) {
259 const cellChildAutoCompleter = getCellChildAutoCompleter();
260 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
261 const updateCellElement = useTableStore(state => state.updateCellElement);
262 const replaceCellElement = useTableStore(state => state.replaceCellElement);
263
264 const searchTerm = useTableStore(state => state.searchTerm);
265 const searchConfig = useTableStore(state => state.table.search);
266 const isPro = isProAvailable();
267
268 const shouldHighlight =
269 isPro &&
270 searchConfig?.enabled &&
271 searchConfig?.highlightColor &&
272 searchTerm.trim();
273
274 const paddingValue = sidesToShorthand(padding);
275 const marginValue = sidesToShorthand(margin);
276
277 const textStyle: Record<string, string | undefined | number> = {
278 "margin": marginValue ?? 0,
279 "padding": paddingValue,
280 "color": textColor || undefined,
281 "fontSize": fontSize || undefined,
282 "backgroundColor": backgroundColor || undefined,
283 "--tableberg-text-link-color": linkColor || undefined,
284 };
285
286 const richTextStyle: Record<string, string | undefined> = {
287 "margin": marginValue,
288 "padding": paddingValue,
289 "color": textColor || undefined,
290 "fontSize": fontSize || undefined,
291 "backgroundColor": backgroundColor || undefined,
292 "--tableberg-text-link-color": linkColor || undefined,
293 };
294
295 const elementClassName = `tableberg-text-element${
296 dynamicBindingEnabled ? " tableberg-dynamic-content" : ""
297 }`;
298
299 if (isDynamicLoading) {
300 return (
301 <p className={elementClassName} style={textStyle}>
302 {__("Loading...", "tableberg")}
303 </p>
304 );
305 }
306
307 if (shouldHighlight) {
308 return (
309 <p className={elementClassName} style={textStyle}>
310 <HighlightedText
311 content={content}
312 searchTerm={searchTerm}
313 highlightColor={searchConfig!.highlightColor}
314 />
315 </p>
316 );
317 }
318
319 if (sortPreviewMode || dynamicBindingEnabled) {
320 return (
321 <RichText.Content
322 tagName="p"
323 className={elementClassName}
324 value={content}
325 style={textStyle}
326 />
327 );
328 }
329
330 return (
331 <RichText
332 className={elementClassName}
333 placeholder={"Type here"}
334 value={content}
335 onChange={content =>
336 updateCellElement(cellCoords, elementIndex, {
337 attributes: { content },
338 })
339 }
340 autocompleters={[cellChildAutoCompleter]}
341 style={richTextStyle}
342 onReplace={(replacement: any[]) => {
343 const first = replacement?.[0];
344
345 // The "/" autocompleter sends a single element-name string.
346 if (typeof first === "string") {
347 const newElement = createElement(
348 first as CellElement["name"]
349 );
350 if (newElement) {
351 replaceCellElement(
352 cellCoords,
353 elementIndex,
354 newElement
355 );
356 }
357 return;
358 }
359
360 // Otherwise this is pasted block content: flatten to text.
361 const text = pastedBlocksToPlainText(replacement);
362 if (text) {
363 updateCellElement(cellCoords, elementIndex, {
364 attributes: { content: text },
365 });
366 }
367 }}
368 />
369 );
370 }
371
372 export const textBindableAttributes: BindableAttribute[] = [
373 {
374 path: "content",
375 label: __("Content", "tableberg"),
376 },
377 {
378 path: "styles.textColor",
379 label: __("Text Color", "tableberg"),
380 },
381 {
382 path: "styles.linkColor",
383 label: __("Link Color", "tableberg"),
384 },
385 {
386 path: "styles.backgroundColor",
387 label: __("Background Color", "tableberg"),
388 },
389 {
390 path: "styles.fontSize",
391 label: __("Font Size", "tableberg"),
392 },
393 ];
394