返回 presentation-ai
PresentationTableToolbarControls.tsx
根目录 / src / components / presentation / floating-toolbar / PresentationTableToolbarControls.tsx
1 "use client";
2
3 import { BlockSelectionPlugin } from "@platejs/selection/react";
4 import { getCellTypes } from "@platejs/table";
5 import {
6 TablePlugin,
7 useTableBordersDropdownMenuContentState,
8 useTableMergeState,
9 } from "@platejs/table/react";
10 import type * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
11 import {
12 ArrowDown,
13 ArrowLeft,
14 ArrowRight,
15 ArrowUp,
16 CombineIcon,
17 EraserIcon,
18 Grid2X2Icon,
19 PaintBucketIcon,
20 SquareSplitHorizontalIcon,
21 Trash2Icon,
22 XIcon,
23 } from "lucide-react";
24 import {
25 ElementApi,
26 KEYS,
27 type NodeEntry,
28 type Path,
29 type TElement,
30 } from "platejs";
31 import {
32 useEditorPlugin,
33 useEditorRef,
34 useEditorSelector,
35 usePluginOption,
36 } from "platejs/react";
37 import * as React from "react";
38
39 import {
40 DropdownMenu,
41 DropdownMenuCheckboxItem,
42 DropdownMenuContent,
43 DropdownMenuGroup,
44 DropdownMenuPortal,
45 DropdownMenuTrigger,
46 } from "@/components/plate/ui/dropdown-menu";
47 import {
48 BorderAllIcon,
49 BorderBottomIcon,
50 BorderLeftIcon,
51 BorderNoneIcon,
52 BorderRightIcon,
53 BorderTopIcon,
54 } from "@/components/plate/ui/table-icons";
55 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
56 import ColorPicker from "@/components/ui/color-picker";
57 import { cn } from "@/lib/utils";
58 import { FLOATING_TOOLBAR_IGNORE_CLASS } from "./toolbar-interaction";
59
60 type TableBlockEntry = NodeEntry<TElement>;
61 type TableSelectionScope = "table" | "row" | "cell";
62
63 const DEFAULT_TABLE_PICKER_COLOR = "#3b82f6";
64
65 function isTableElement(element: TElement | undefined) {
66 return element?.type === KEYS.table;
67 }
68
69 function isRowElement(element: TElement | undefined) {
70 return element?.type === KEYS.tr;
71 }
72
73 function getElementBackground(element: TElement | undefined) {
74 const background = element?.background;
75 return typeof background === "string"
76 ? background
77 : DEFAULT_TABLE_PICKER_COLOR;
78 }
79
80 export function PresentationTableToolbarControls() {
81 const { editor, tf } = useEditorPlugin(TablePlugin);
82 const isSelectionInTable = useEditorSelector(
83 (currentEditor) => currentEditor.api.some({ match: { type: KEYS.table } }),
84 [],
85 );
86 const selectedIds = usePluginOption(BlockSelectionPlugin, "selectedIds");
87 const selectedCells = usePluginOption(TablePlugin, "selectedCells");
88 const { canMerge, canSplit } = useTableMergeState();
89
90 const selectedBlockEntry = React.useMemo<TableBlockEntry | undefined>(() => {
91 for (const blockId of selectedIds ?? []) {
92 const entry = editor.api.node({
93 at: [],
94 id: String(blockId),
95 }) as TableBlockEntry | undefined;
96 const [element] = entry ?? [];
97
98 if (isTableElement(element) || isRowElement(element)) return entry;
99 }
100
101 return undefined;
102 }, [editor, selectedIds]);
103
104 const selectedTableEntry = React.useMemo<TableBlockEntry | undefined>(() => {
105 const [selectedElement, selectedPath] = selectedBlockEntry ?? [];
106
107 if (isTableElement(selectedElement)) return selectedBlockEntry;
108
109 if (isRowElement(selectedElement) && selectedPath) {
110 const tablePath = selectedPath.slice(0, -1);
111 const tableElement = editor.api.node({ at: tablePath })?.[0];
112
113 if (ElementApi.isElement(tableElement) && isTableElement(tableElement)) {
114 return [tableElement, tablePath];
115 }
116 }
117
118 const tableEntry = editor.api.above({
119 match: { type: KEYS.table },
120 }) as TableBlockEntry | undefined;
121
122 return tableEntry;
123 }, [editor, selectedBlockEntry]);
124
125 const selectedRowEntry = React.useMemo<TableBlockEntry | undefined>(() => {
126 const [selectedElement] = selectedBlockEntry ?? [];
127
128 if (isRowElement(selectedElement)) return selectedBlockEntry;
129
130 return editor.api.above({
131 match: { type: KEYS.tr },
132 }) as TableBlockEntry | undefined;
133 }, [editor, selectedBlockEntry]);
134
135 const hasSelectedCells = (selectedCells?.length ?? 0) > 0;
136 const blockScope = isTableElement(selectedBlockEntry?.[0])
137 ? "table"
138 : isRowElement(selectedBlockEntry?.[0])
139 ? "row"
140 : undefined;
141 const scope: TableSelectionScope =
142 blockScope ?? (hasSelectedCells || isSelectionInTable ? "cell" : "table");
143 const tableSelected = Boolean(selectedTableEntry) || isSelectionInTable;
144 const cellToolbarActive = scope === "cell";
145 const rowToolbarActive = scope === "row";
146 const tableToolbarActive = scope === "table";
147 const colorTooltip =
148 scope === "table"
149 ? "Table background"
150 : scope === "row"
151 ? "Row background"
152 : "Cell background";
153 const pickerColor = getElementBackground(
154 scope === "row"
155 ? selectedRowEntry?.[0]
156 : scope === "table"
157 ? selectedTableEntry?.[0]
158 : selectedCells?.[0],
159 );
160
161 const getFirstCellPath = React.useCallback(
162 (tableEntry: TableBlockEntry | undefined): Path | undefined => {
163 const [tableElement, tablePath] = tableEntry ?? [];
164 if (!tableElement || !tablePath) return undefined;
165
166 for (
167 let rowIndex = 0;
168 rowIndex < tableElement.children.length;
169 rowIndex += 1
170 ) {
171 const row = tableElement.children[rowIndex];
172 if (!ElementApi.isElement(row)) continue;
173
174 for (
175 let cellIndex = 0;
176 cellIndex < row.children.length;
177 cellIndex += 1
178 ) {
179 const cell = row.children[cellIndex];
180 if (ElementApi.isElement(cell)) {
181 return [...tablePath, rowIndex, cellIndex];
182 }
183 }
184 }
185
186 return undefined;
187 },
188 [],
189 );
190
191 const getLastCellPath = React.useCallback(
192 (tableEntry: TableBlockEntry | undefined): Path | undefined => {
193 const [tableElement, tablePath] = tableEntry ?? [];
194 if (!tableElement || !tablePath) return undefined;
195
196 for (
197 let rowIndex = tableElement.children.length - 1;
198 rowIndex >= 0;
199 rowIndex -= 1
200 ) {
201 const row = tableElement.children[rowIndex];
202 if (!ElementApi.isElement(row)) continue;
203
204 for (
205 let cellIndex = row.children.length - 1;
206 cellIndex >= 0;
207 cellIndex -= 1
208 ) {
209 const cell = row.children[cellIndex];
210 if (ElementApi.isElement(cell)) {
211 return [...tablePath, rowIndex, cellIndex];
212 }
213 }
214 }
215
216 return undefined;
217 },
218 [],
219 );
220
221 const getTargetCellPath = React.useCallback((): Path | undefined => {
222 const selectedCell = selectedCells?.[0];
223
224 if (selectedCell) {
225 const selectedCellPath = editor.api.findPath(selectedCell);
226 if (selectedCellPath) return selectedCellPath;
227 }
228
229 const currentCellEntry = editor.api.above({
230 match: { type: getCellTypes(editor) },
231 }) as NodeEntry<TElement> | undefined;
232
233 return currentCellEntry?.[1] ?? getFirstCellPath(selectedTableEntry);
234 }, [editor, getFirstCellPath, selectedCells, selectedTableEntry]);
235
236 const selectTargetCell = React.useCallback(() => {
237 const targetCellPath = getTargetCellPath();
238 if (!targetCellPath) return false;
239
240 editor.tf.select(targetCellPath);
241 return true;
242 }, [editor, getTargetCellPath]);
243
244 const updateCellsInEntry = React.useCallback(
245 (entry: TableBlockEntry | undefined, color: string | null) => {
246 const [element, path] = entry ?? [];
247 if (!element || !path) return;
248
249 if (isTableElement(element)) {
250 element.children.forEach((row, rowIndex) => {
251 if (!ElementApi.isElement(row)) return;
252
253 row.children.forEach((cell, cellIndex) => {
254 if (!ElementApi.isElement(cell)) return;
255
256 editor.tf.setNodes(
257 { background: color },
258 { at: [...path, rowIndex, cellIndex] },
259 );
260 });
261 });
262 return;
263 }
264
265 if (isRowElement(element)) {
266 element.children.forEach((cell, cellIndex) => {
267 if (!ElementApi.isElement(cell)) return;
268
269 editor.tf.setNodes(
270 { background: color },
271 { at: [...path, cellIndex] },
272 );
273 });
274 }
275 },
276 [editor],
277 );
278
279 const updateBackground = React.useCallback(
280 (color: string | null) => {
281 if (scope === "table") {
282 updateCellsInEntry(selectedTableEntry, color);
283 editor.tf.focus();
284 return;
285 }
286
287 if (scope === "row") {
288 updateCellsInEntry(selectedRowEntry, color);
289 editor.tf.focus();
290 return;
291 }
292
293 if (selectedCells && selectedCells.length > 0) {
294 selectedCells.forEach((cell) => {
295 if (!ElementApi.isElement(cell)) return;
296
297 const cellPath = editor.api.findPath(cell);
298 if (cellPath) {
299 editor.tf.setNodes({ background: color }, { at: cellPath });
300 }
301 });
302 editor.tf.focus();
303 return;
304 }
305
306 const targetCellPath = getTargetCellPath();
307 if (!targetCellPath) return;
308
309 editor.tf.setNodes({ background: color }, { at: targetCellPath });
310 editor.tf.focus();
311 },
312 [
313 editor,
314 getTargetCellPath,
315 scope,
316 selectedCells,
317 selectedRowEntry,
318 selectedTableEntry,
319 updateCellsInEntry,
320 ],
321 );
322
323 const getRowPath = React.useCallback(
324 (before?: boolean): Path | undefined => {
325 if (selectedRowEntry) return selectedRowEntry[1];
326
327 const currentRowEntry = editor.api.above({
328 match: { type: KEYS.tr },
329 }) as TableBlockEntry | undefined;
330
331 if (currentRowEntry) return currentRowEntry[1];
332
333 const [tableElement, tablePath] = selectedTableEntry ?? [];
334 if (!tableElement || !tablePath) return undefined;
335
336 const rowIndex = before ? 0 : tableElement.children.length - 1;
337 return [...tablePath, rowIndex];
338 },
339 [editor, selectedRowEntry, selectedTableEntry],
340 );
341
342 const insertRow = React.useCallback(
343 (before?: boolean) => {
344 const rowPath = getRowPath(before);
345
346 if (rowPath) {
347 tf.insert.tableRow({ before, fromRow: rowPath, select: true });
348 } else {
349 tf.insert.tableRow({ before, select: true });
350 }
351
352 editor.tf.focus();
353 },
354 [editor, getRowPath, tf],
355 );
356
357 const insertColumn = React.useCallback(
358 (before?: boolean) => {
359 const targetCellPath =
360 scope === "table" && !isSelectionInTable
361 ? before
362 ? getFirstCellPath(selectedTableEntry)
363 : getLastCellPath(selectedTableEntry)
364 : getTargetCellPath();
365
366 if (targetCellPath) {
367 tf.insert.tableColumn({
368 before,
369 fromCell: targetCellPath,
370 select: true,
371 });
372 } else {
373 tf.insert.tableColumn({ before, select: true });
374 }
375
376 editor.tf.focus();
377 },
378 [
379 editor,
380 getFirstCellPath,
381 getLastCellPath,
382 getTargetCellPath,
383 isSelectionInTable,
384 scope,
385 selectedTableEntry,
386 tf,
387 ],
388 );
389
390 const runCellScopedAction = React.useCallback(
391 (action: () => void) => {
392 if (!hasSelectedCells && !selectTargetCell()) return;
393
394 action();
395 editor.tf.focus();
396 },
397 [editor, hasSelectedCells, selectTargetCell],
398 );
399
400 const removeSelectedTable = React.useCallback(() => {
401 if (selectedTableEntry) {
402 editor.tf.removeNodes({ at: selectedTableEntry[1] });
403 } else {
404 tf.remove.table();
405 }
406
407 editor.tf.focus();
408 }, [editor, selectedTableEntry, tf]);
409
410 return (
411 <>
412 <ToolbarGroup>
413 <ColorPicker value={pickerColor} onChange={updateBackground}>
414 <ToolbarButton
415 disabled={!tableSelected}
416 size="sm"
417 tooltip={colorTooltip}
418 >
419 <PaintBucketIcon className="h-4 w-4" />
420 </ToolbarButton>
421 </ColorPicker>
422 <ToolbarButton
423 disabled={!tableSelected}
424 onClick={() => updateBackground(null)}
425 onMouseDown={(event) => event.preventDefault()}
426 size="sm"
427 tooltip="Clear background"
428 >
429 <EraserIcon className="h-4 w-4" />
430 </ToolbarButton>
431 </ToolbarGroup>
432
433 {cellToolbarActive && (
434 <ToolbarGroup>
435 <ToolbarButton
436 disabled={!canMerge}
437 onClick={() => runCellScopedAction(() => tf.table.merge())}
438 onMouseDown={(event) => event.preventDefault()}
439 size="sm"
440 tooltip="Merge selected cells"
441 >
442 <CombineIcon className="h-4 w-4" />
443 </ToolbarButton>
444 <ToolbarButton
445 disabled={!canSplit}
446 onClick={() => runCellScopedAction(() => tf.table.split())}
447 onMouseDown={(event) => event.preventDefault()}
448 size="sm"
449 tooltip="Split merged cell"
450 >
451 <SquareSplitHorizontalIcon className="h-4 w-4" />
452 </ToolbarButton>
453
454 <DropdownMenu modal={false}>
455 <DropdownMenuTrigger asChild>
456 <ToolbarButton
457 disabled={!tableSelected}
458 size="sm"
459 tooltip="Cell borders"
460 >
461 <Grid2X2Icon className="h-4 w-4" />
462 </ToolbarButton>
463 </DropdownMenuTrigger>
464
465 <DropdownMenuPortal>
466 <TableBordersDropdownMenuContent />
467 </DropdownMenuPortal>
468 </DropdownMenu>
469 </ToolbarGroup>
470 )}
471
472 {(cellToolbarActive || rowToolbarActive || tableToolbarActive) && (
473 <ToolbarGroup>
474 <ToolbarButton
475 disabled={!tableSelected}
476 onClick={() => insertRow(true)}
477 onMouseDown={(event) => event.preventDefault()}
478 size="sm"
479 tooltip="Insert row above"
480 >
481 <ArrowUp className="h-4 w-4" />
482 </ToolbarButton>
483 <ToolbarButton
484 disabled={!tableSelected}
485 onClick={() => insertRow()}
486 onMouseDown={(event) => event.preventDefault()}
487 size="sm"
488 tooltip="Insert row below"
489 >
490 <ArrowDown className="h-4 w-4" />
491 </ToolbarButton>
492 {(cellToolbarActive || rowToolbarActive) && (
493 <ToolbarButton
494 disabled={!tableSelected}
495 onClick={() => runCellScopedAction(() => tf.remove.tableRow())}
496 onMouseDown={(event) => event.preventDefault()}
497 size="sm"
498 tooltip="Delete row"
499 >
500 <XIcon className="h-4 w-4" />
501 </ToolbarButton>
502 )}
503 </ToolbarGroup>
504 )}
505
506 {(cellToolbarActive || tableToolbarActive) && (
507 <ToolbarGroup>
508 <ToolbarButton
509 disabled={!tableSelected}
510 onClick={() => insertColumn(true)}
511 onMouseDown={(event) => event.preventDefault()}
512 size="sm"
513 tooltip="Insert column left"
514 >
515 <ArrowLeft className="h-4 w-4" />
516 </ToolbarButton>
517 <ToolbarButton
518 disabled={!tableSelected}
519 onClick={() => insertColumn()}
520 onMouseDown={(event) => event.preventDefault()}
521 size="sm"
522 tooltip="Insert column right"
523 >
524 <ArrowRight className="h-4 w-4" />
525 </ToolbarButton>
526 {cellToolbarActive && (
527 <ToolbarButton
528 disabled={!tableSelected}
529 onClick={() => runCellScopedAction(() => tf.remove.tableColumn())}
530 onMouseDown={(event) => event.preventDefault()}
531 size="sm"
532 tooltip="Delete column"
533 >
534 <XIcon className="h-4 w-4" />
535 </ToolbarButton>
536 )}
537 </ToolbarGroup>
538 )}
539
540 {tableToolbarActive && (
541 <ToolbarGroup>
542 <ToolbarButton
543 disabled={!tableSelected}
544 onClick={removeSelectedTable}
545 onMouseDown={(event) => event.preventDefault()}
546 size="sm"
547 tooltip="Delete table"
548 >
549 <Trash2Icon className="h-4 w-4" />
550 </ToolbarButton>
551 </ToolbarGroup>
552 )}
553 </>
554 );
555 }
556
557 function TableBordersDropdownMenuContent(
558 props: React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
559 ) {
560 const editor = useEditorRef();
561 const {
562 getOnSelectTableBorder,
563 hasBottomBorder,
564 hasLeftBorder,
565 hasNoBorders,
566 hasOuterBorders,
567 hasRightBorder,
568 hasTopBorder,
569 } = useTableBordersDropdownMenuContentState();
570
571 return (
572 <DropdownMenuContent
573 align="start"
574 className={cn(FLOATING_TOOLBAR_IGNORE_CLASS, "min-w-55")}
575 onCloseAutoFocus={(event) => {
576 event.preventDefault();
577 editor.tf.focus();
578 }}
579 side="right"
580 sideOffset={0}
581 {...props}
582 >
583 <DropdownMenuGroup>
584 <DropdownMenuCheckboxItem
585 checked={hasTopBorder}
586 onCheckedChange={getOnSelectTableBorder("top")}
587 >
588 <BorderTopIcon />
589 <div>Top border</div>
590 </DropdownMenuCheckboxItem>
591 <DropdownMenuCheckboxItem
592 checked={hasRightBorder}
593 onCheckedChange={getOnSelectTableBorder("right")}
594 >
595 <BorderRightIcon />
596 <div>Right border</div>
597 </DropdownMenuCheckboxItem>
598 <DropdownMenuCheckboxItem
599 checked={hasBottomBorder}
600 onCheckedChange={getOnSelectTableBorder("bottom")}
601 >
602 <BorderBottomIcon />
603 <div>Bottom border</div>
604 </DropdownMenuCheckboxItem>
605 <DropdownMenuCheckboxItem
606 checked={hasLeftBorder}
607 onCheckedChange={getOnSelectTableBorder("left")}
608 >
609 <BorderLeftIcon />
610 <div>Left border</div>
611 </DropdownMenuCheckboxItem>
612 </DropdownMenuGroup>
613
614 <DropdownMenuGroup>
615 <DropdownMenuCheckboxItem
616 checked={hasNoBorders}
617 onCheckedChange={getOnSelectTableBorder("none")}
618 >
619 <BorderNoneIcon />
620 <div>No border</div>
621 </DropdownMenuCheckboxItem>
622 <DropdownMenuCheckboxItem
623 checked={hasOuterBorders}
624 onCheckedChange={getOnSelectTableBorder("outer")}
625 >
626 <BorderAllIcon />
627 <div>Outside borders</div>
628 </DropdownMenuCheckboxItem>
629 </DropdownMenuGroup>
630 </DropdownMenuContent>
631 );
632 }
633
633 lines Plain Text