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

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

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