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 / components / TableEditToolbar / index.tsx

index.tsx in Tableberg – Simple Gutenberg Table Block 1.1.5, at src/components/TableEditToolbar/index.tsx

539 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { RefObject, useEffect, useRef, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { ToolbarButton } from "@wordpress/components";
4 import { __ } from "@wordpress/i18n";
5 import {
6 arrowDown,
7 arrowLeft,
8 arrowRight,
9 arrowUp,
10 tableColumnAfter,
11 tableColumnBefore,
12 tableColumnDelete,
13 tableRowAfter,
14 tableRowBefore,
15 tableRowDelete,
16 } from "@wordpress/icons";
17 import {
18 DuplicateColumnIcon,
19 DuplicateRowIcon,
20 } from "@tableberg/shared/icons/enhancements";
21 import { parseCellKey } from "../../attributes";
22 import { useTableStore } from "../../store";
23 import type { TableEditPreview } from "../../store";
24
25 interface ToolbarButtonInteractions {
26 onEnter?: () => void;
27 onLeave?: () => void;
28 onFocus?: () => void;
29 onBlur?: () => void;
30 }
31
32 interface TableEditToolbarButtonProps
33 extends Omit<React.ComponentProps<typeof ToolbarButton>, "showTooltip"> {
34 tooltip: string;
35 interactions?: ToolbarButtonInteractions;
36 }
37
38 function TableEditToolbarButton({
39 tooltip,
40 interactions,
41 ...props
42 }: TableEditToolbarButtonProps) {
43 // The hover/focus interactions drive the row/column preview highlight; the
44 // label is shown via the native WordPress tooltip, which positions reliably
45 // inside the portaled/transformed toolbar.
46 return (
47 <span
48 className="tableberg-edit-toolbar-button-anchor"
49 onMouseEnter={() => interactions?.onEnter?.()}
50 onMouseLeave={() => interactions?.onLeave?.()}
51 onFocus={() => interactions?.onFocus?.()}
52 onBlur={() => interactions?.onBlur?.()}
53 >
54 <ToolbarButton {...props} label={tooltip} showTooltip />
55 </span>
56 );
57 }
58
59 function getPortalDocument(sourceDocument: Document): Document {
60 try {
61 return sourceDocument.defaultView?.parent?.document || sourceDocument;
62 } catch {
63 return sourceDocument;
64 }
65 }
66
67 interface TableEditToolbarProps {
68 anchorRef: RefObject<HTMLElement | null>;
69 }
70
71 export function TableEditToolbar({ anchorRef }: TableEditToolbarProps) {
72 const selectedCells = useTableStore(state => state.selectedCells);
73 const cells = useTableStore(state => state.cells);
74 const rows = useTableStore(state => state.table.rows);
75 const cols = useTableStore(state => state.table.cols);
76 const insertRow = useTableStore(state => state.insertRow);
77 const deleteRow = useTableStore(state => state.deleteRow);
78 const insertColumn = useTableStore(state => state.insertColumn);
79 const deleteColumn = useTableStore(state => state.deleteColumn);
80 const duplicateRow = useTableStore(state => state.duplicateRow);
81 const duplicateColumn = useTableStore(state => state.duplicateColumn);
82 const moveRow = useTableStore(state => state.moveRow);
83 const moveColumn = useTableStore(state => state.moveColumn);
84 const showRowColumnControls = useTableStore(
85 state => state.showRowColumnControls
86 );
87 const showDuplicateMoveControls = useTableStore(
88 state => state.showDuplicateMoveControls
89 );
90 const setTableEditPreview = useTableStore(
91 state => state.setTableEditPreview
92 );
93 const clearTableEditPreview = useTableStore(
94 state => state.clearTableEditPreview
95 );
96 const tableEditPreview = useTableStore(state => state.tableEditPreview);
97
98 const toolbarRef = useRef<HTMLDivElement | null>(null);
99 const portalMountRef = useRef<HTMLDivElement | null>(null);
100 const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
101 const [toolbarPosition, setToolbarPosition] = useState<{
102 top: number;
103 left: number;
104 } | null>(null);
105
106 const primaryCell = selectedCells[0];
107
108 useEffect(
109 () => () => {
110 if (tableEditPreview !== null) {
111 clearTableEditPreview();
112 }
113 },
114 [clearTableEditPreview, tableEditPreview]
115 );
116
117 useEffect(() => {
118 if (tableEditPreview !== null) {
119 clearTableEditPreview();
120 }
121 }, [
122 showRowColumnControls,
123 showDuplicateMoveControls,
124 clearTableEditPreview,
125 tableEditPreview,
126 ]);
127
128 useEffect(() => {
129 const anchorElement = anchorRef.current;
130
131 if (!anchorElement) {
132 setPortalTarget(null);
133 setToolbarPosition(null);
134 return;
135 }
136
137 const ownerDocument = anchorElement.ownerDocument;
138 const portalDocument = getPortalDocument(ownerDocument);
139 const portalWindow = portalDocument.defaultView;
140 const ownerWindow = ownerDocument.defaultView;
141
142 if (!portalMountRef.current) {
143 portalMountRef.current = portalDocument.createElement("div");
144 portalMountRef.current.className = "tableberg-edit-toolbar-mount";
145 }
146
147 const mountNode = portalMountRef.current;
148
149 if (mountNode.parentElement !== portalDocument.body) {
150 portalDocument.body.appendChild(mountNode);
151 }
152
153 setPortalTarget(mountNode);
154
155 let frameId: number | null = null;
156
157 const syncPosition = () => {
158 const currentAnchor = anchorRef.current;
159
160 if (!currentAnchor) {
161 setToolbarPosition(null);
162 return;
163 }
164
165 // Anchor to the actual table (inside any block padding/alignment),
166 // not the outer block figure, so the rail stays attached to the
167 // table when block padding is applied.
168 const tableElement =
169 currentAnchor.querySelector(".tableberg-table-wrapper") ||
170 currentAnchor.querySelector("table") ||
171 currentAnchor;
172
173 const anchorRect = tableElement.getBoundingClientRect();
174 let top = anchorRect.top;
175 let left = anchorRect.left - 8;
176
177 if (ownerDocument !== portalDocument) {
178 const frameElement = ownerWindow?.frameElement;
179 const frameRect = frameElement?.getBoundingClientRect() || null;
180
181 if (frameRect) {
182 top += frameRect.top;
183 left += frameRect.left;
184 }
185 }
186
187 setToolbarPosition({ top, left });
188 };
189
190 const schedulePositionSync = () => {
191 if (!portalWindow) {
192 syncPosition();
193 return;
194 }
195
196 if (frameId !== null) {
197 return;
198 }
199
200 frameId = portalWindow.requestAnimationFrame(() => {
201 frameId = null;
202 syncPosition();
203 });
204 };
205
206 const resizeObserver = new ResizeObserver(() => {
207 schedulePositionSync();
208 });
209
210 resizeObserver.observe(anchorElement);
211
212 ownerWindow?.addEventListener("resize", schedulePositionSync);
213 ownerDocument.addEventListener("scroll", schedulePositionSync, true);
214
215 if (portalWindow && portalWindow !== ownerWindow) {
216 portalWindow.addEventListener("resize", schedulePositionSync);
217 portalDocument.addEventListener(
218 "scroll",
219 schedulePositionSync,
220 true
221 );
222 }
223
224 schedulePositionSync();
225
226 return () => {
227 resizeObserver.disconnect();
228 ownerWindow?.removeEventListener("resize", schedulePositionSync);
229 ownerDocument.removeEventListener(
230 "scroll",
231 schedulePositionSync,
232 true
233 );
234
235 if (portalWindow && portalWindow !== ownerWindow) {
236 portalWindow.removeEventListener(
237 "resize",
238 schedulePositionSync
239 );
240 portalDocument.removeEventListener(
241 "scroll",
242 schedulePositionSync,
243 true
244 );
245 }
246
247 if (frameId !== null && portalWindow) {
248 portalWindow.cancelAnimationFrame(frameId);
249 }
250
251 if (mountNode.parentElement) {
252 mountNode.parentElement.removeChild(mountNode);
253 }
254
255 setPortalTarget(null);
256 };
257 }, [anchorRef]);
258
259 if (!primaryCell) {
260 return null;
261 }
262
263 if (!showRowColumnControls && !showDuplicateMoveControls) {
264 return null;
265 }
266
267 if (!portalTarget || !toolbarPosition) {
268 return null;
269 }
270
271 const [row, col] = parseCellKey(primaryCell);
272 const hasMergedCells = Object.values(cells).some(cell => {
273 const span = cell.span;
274 return !!span && (span.rowSpan > 1 || span.colSpan > 1);
275 });
276 const advancedDisabled = hasMergedCells;
277
278 const getButtonInteractions = (
279 preview: TableEditPreview,
280 disablePreview = false
281 ): ToolbarButtonInteractions => {
282 if (disablePreview || !preview) {
283 return {};
284 }
285
286 return {
287 onEnter: () => {
288 setTableEditPreview(preview);
289 },
290 onLeave: () => {
291 clearTableEditPreview();
292 },
293 onFocus: () => {
294 setTableEditPreview(preview);
295 },
296 onBlur: () => {
297 clearTableEditPreview();
298 },
299 };
300 };
301
302 return createPortal(
303 <div
304 ref={toolbarRef}
305 className="tableberg-edit-toolbar"
306 role="toolbar"
307 aria-label={__("Edit table", "tableberg")}
308 style={{
309 top: `${toolbarPosition.top}px`,
310 left: `${toolbarPosition.left}px`,
311 }}
312 >
313 {showDuplicateMoveControls && (
314 <div className="tableberg-edit-toolbar-column">
315 <div className="tableberg-edit-toolbar-group tableberg-edit-toolbar-group-advanced">
316 <TableEditToolbarButton
317 tooltip={
318 advancedDisabled
319 ? __(
320 "Duplicate and move controls are disabled for merged tables.",
321 "tableberg"
322 )
323 : __("Duplicate row", "tableberg")
324 }
325 interactions={getButtonInteractions(
326 {
327 operation: "duplicate",
328 target: "row",
329 index: row + 1,
330 },
331 advancedDisabled
332 )}
333 icon={DuplicateRowIcon}
334 label={__("Duplicate row", "tableberg")}
335 disabled={advancedDisabled}
336 onClick={() => {
337 clearTableEditPreview();
338 duplicateRow(row);
339 }}
340 />
341 <TableEditToolbarButton
342 tooltip={
343 advancedDisabled
344 ? __(
345 "Duplicate and move controls are disabled for merged tables.",
346 "tableberg"
347 )
348 : __("Duplicate column", "tableberg")
349 }
350 interactions={getButtonInteractions(
351 {
352 operation: "duplicate",
353 target: "column",
354 index: col + 1,
355 },
356 advancedDisabled
357 )}
358 icon={DuplicateColumnIcon}
359 label={__("Duplicate column", "tableberg")}
360 disabled={advancedDisabled}
361 onClick={() => {
362 clearTableEditPreview();
363 duplicateColumn(col);
364 }}
365 />
366 <TableEditToolbarButton
367 tooltip={
368 advancedDisabled
369 ? __(
370 "Duplicate and move controls are disabled for merged tables.",
371 "tableberg"
372 )
373 : __("Move row up", "tableberg")
374 }
375 icon={arrowUp}
376 label={__("Move row up", "tableberg")}
377 disabled={advancedDisabled || row === 0}
378 onClick={() => {
379 moveRow(row, row - 1);
380 }}
381 />
382 <TableEditToolbarButton
383 tooltip={
384 advancedDisabled
385 ? __(
386 "Duplicate and move controls are disabled for merged tables.",
387 "tableberg"
388 )
389 : __("Move row down", "tableberg")
390 }
391 icon={arrowDown}
392 label={__("Move row down", "tableberg")}
393 disabled={advancedDisabled || row >= rows - 1}
394 onClick={() => {
395 moveRow(row, row + 1);
396 }}
397 />
398 <TableEditToolbarButton
399 tooltip={
400 advancedDisabled
401 ? __(
402 "Duplicate and move controls are disabled for merged tables.",
403 "tableberg"
404 )
405 : __("Move column left", "tableberg")
406 }
407 icon={arrowLeft}
408 label={__("Move column left", "tableberg")}
409 disabled={advancedDisabled || col === 0}
410 onClick={() => {
411 moveColumn(col, col - 1);
412 }}
413 />
414 <TableEditToolbarButton
415 tooltip={
416 advancedDisabled
417 ? __(
418 "Duplicate and move controls are disabled for merged tables.",
419 "tableberg"
420 )
421 : __("Move column right", "tableberg")
422 }
423 icon={arrowRight}
424 label={__("Move column right", "tableberg")}
425 disabled={advancedDisabled || col >= cols - 1}
426 onClick={() => {
427 moveColumn(col, col + 1);
428 }}
429 />
430 </div>
431 </div>
432 )}
433
434 {showRowColumnControls && (
435 <div className="tableberg-edit-toolbar-column">
436 <div className="tableberg-edit-toolbar-group">
437 <TableEditToolbarButton
438 tooltip={__("Insert row before", "tableberg")}
439 interactions={getButtonInteractions({
440 operation: "insert",
441 target: "row",
442 index: row,
443 })}
444 icon={tableRowBefore}
445 label={__("Insert row before", "tableberg")}
446 onClick={() => {
447 clearTableEditPreview();
448 insertRow(row);
449 }}
450 />
451 <TableEditToolbarButton
452 tooltip={__("Insert row after", "tableberg")}
453 interactions={getButtonInteractions({
454 operation: "insert",
455 target: "row",
456 index: row + 1,
457 })}
458 icon={tableRowAfter}
459 label={__("Insert row after", "tableberg")}
460 onClick={() => {
461 clearTableEditPreview();
462 insertRow(row + 1);
463 }}
464 />
465 <TableEditToolbarButton
466 tooltip={__("Delete row", "tableberg")}
467 interactions={getButtonInteractions(
468 {
469 operation: "delete",
470 target: "row",
471 index: row,
472 },
473 rows <= 1
474 )}
475 icon={tableRowDelete}
476 label={__("Delete row", "tableberg")}
477 disabled={rows <= 1}
478 onClick={() => {
479 clearTableEditPreview();
480 deleteRow(row);
481 }}
482 />
483 </div>
484
485 <div className="tableberg-edit-toolbar-group">
486 <TableEditToolbarButton
487 tooltip={__("Insert column before", "tableberg")}
488 interactions={getButtonInteractions({
489 operation: "insert",
490 target: "column",
491 index: col,
492 })}
493 icon={tableColumnBefore}
494 label={__("Insert column before", "tableberg")}
495 onClick={() => {
496 clearTableEditPreview();
497 insertColumn(col);
498 }}
499 />
500 <TableEditToolbarButton
501 tooltip={__("Insert column after", "tableberg")}
502 interactions={getButtonInteractions({
503 operation: "insert",
504 target: "column",
505 index: col + 1,
506 })}
507 icon={tableColumnAfter}
508 label={__("Insert column after", "tableberg")}
509 onClick={() => {
510 clearTableEditPreview();
511 insertColumn(col + 1);
512 }}
513 />
514 <TableEditToolbarButton
515 tooltip={__("Delete column", "tableberg")}
516 interactions={getButtonInteractions(
517 {
518 operation: "delete",
519 target: "column",
520 index: col,
521 },
522 cols <= 1
523 )}
524 icon={tableColumnDelete}
525 label={__("Delete column", "tableberg")}
526 disabled={cols <= 1}
527 onClick={() => {
528 clearTableEditPreview();
529 deleteColumn(col);
530 }}
531 />
532 </div>
533 </div>
534 )}
535 </div>,
536 portalTarget
537 );
538 }
539