返回 presentation-ai
presentation-table-node.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / presentation-table-node.tsx
1 "use client";
2
3 import {
4 DndPlugin,
5 useDropLine,
6 type DragItemNode,
7 type ElementDragItemNode,
8 } from "@platejs/dnd";
9 import {
10 BlockSelectionPlugin,
11 useBlockSelected,
12 } from "@platejs/selection/react";
13 import { isSelectingCell } from "@platejs/table";
14 import {
15 TablePlugin,
16 TableProvider,
17 useTableCellElement,
18 useTableCellElementResizable,
19 useTableElement,
20 } from "@platejs/table/react";
21 import { GripVertical, Plus } from "lucide-react";
22 import {
23 ElementApi,
24 KEYS,
25 PathApi,
26 type Path,
27 type TTableCellElement,
28 type TTableElement,
29 type TTableRowElement,
30 } from "platejs";
31 import {
32 PlateElement,
33 useEditorPlugin,
34 useEditorRef,
35 useEditorSelector,
36 useElement,
37 useElementSelector,
38 usePluginOption,
39 useReadOnly,
40 withHOC,
41 type PlateEditor,
42 type PlateElementProps,
43 } from "platejs/react";
44 import * as React from "react";
45 import { type DropTargetMonitor } from "react-dnd";
46
47 import { Button } from "@/components/plate/ui/button";
48 import { ResizeHandle } from "@/components/plate/ui/resize-handle";
49 import { cn } from "@/lib/utils";
50 import { blockSelectionVariants } from "../../../../plate/ui/block-selection";
51 import { PresentationElement } from "../custom-elements/presentation-element";
52 import { useDraggable } from "../dnd/hooks/useDraggable";
53
54 const TABLE_ROW_DRAG_TYPE = "presentation-table-row";
55
56 function setRefValue<T>(ref: React.Ref<T> | undefined, value: T | null) {
57 if (typeof ref === "function") {
58 ref(value);
59 return;
60 }
61
62 if (ref) {
63 ref.current = value;
64 }
65 }
66
67 function isElementDragItem(
68 dragItem: DragItemNode,
69 ): dragItem is ElementDragItemNode {
70 return "element" in dragItem && "id" in dragItem;
71 }
72
73 function getRowDropDirection(
74 monitor: DropTargetMonitor<DragItemNode, unknown>,
75 row: HTMLTableRowElement | null,
76 ) {
77 if (!row) return;
78
79 const clientOffset = monitor.getClientOffset();
80 if (!clientOffset) return;
81
82 const rect = row.getBoundingClientRect();
83 const hoverClientY = clientOffset.y - rect.top;
84
85 return hoverClientY < rect.height / 2 ? "top" : "bottom";
86 }
87
88 function canDropTableRow(
89 editor: PlateEditor,
90 dragItem: DragItemNode,
91 dropElement: TTableRowElement,
92 ) {
93 if (!isElementDragItem(dragItem)) return false;
94 if (dragItem.element === dropElement) return false;
95
96 const dragPath = editor.api.findPath(dragItem.element);
97 const dropPath = editor.api.findPath(dropElement);
98
99 return Boolean(
100 dragPath &&
101 dropPath &&
102 PathApi.equals(PathApi.parent(dragPath), PathApi.parent(dropPath)),
103 );
104 }
105
106 function updateRowDropLine(
107 editor: PlateEditor,
108 element: TTableRowElement,
109 direction: "bottom" | "top" | undefined,
110 ) {
111 const { dropTarget } = editor.getOptions(DndPlugin);
112 const currentId = dropTarget?.id ?? null;
113 const currentLine = dropTarget?.line ?? "";
114 const nextId = direction ? (element.id as string) : null;
115 const nextLine = direction ?? "";
116
117 if (currentId !== nextId || currentLine !== nextLine) {
118 editor.setOption(DndPlugin, "dropTarget", {
119 id: nextId,
120 line: nextLine,
121 });
122 }
123 }
124
125 function moveTableRow(
126 editor: PlateEditor,
127 dragItem: DragItemNode,
128 dropElement: TTableRowElement,
129 direction: "bottom" | "top",
130 ) {
131 if (!isElementDragItem(dragItem)) return false;
132
133 const dragPath = editor.api.findPath(dragItem.element);
134 const hoveredPath = editor.api.findPath(dropElement);
135
136 if (!dragPath || !hoveredPath) return false;
137 if (!PathApi.equals(PathApi.parent(dragPath), PathApi.parent(hoveredPath))) {
138 return false;
139 }
140
141 if (direction === "bottom") {
142 if (PathApi.equals(dragPath, PathApi.next(hoveredPath))) return true;
143
144 const to =
145 PathApi.isBefore(dragPath, hoveredPath) &&
146 PathApi.isSibling(dragPath, hoveredPath)
147 ? hoveredPath
148 : PathApi.next(hoveredPath);
149
150 editor.tf.moveNodes({ at: dragPath, to });
151 return true;
152 }
153
154 const previousPath = PathApi.previous(hoveredPath);
155 if (previousPath && PathApi.equals(dragPath, previousPath)) return true;
156
157 const beforePath = [
158 ...hoveredPath.slice(0, -1),
159 (hoveredPath.at(-1) ?? 0) - 1,
160 ];
161 const to =
162 PathApi.isBefore(dragPath, beforePath) &&
163 PathApi.isSibling(dragPath, beforePath)
164 ? beforePath
165 : hoveredPath;
166
167 editor.tf.moveNodes({ at: dragPath, to });
168 return true;
169 }
170
171 function getLastTableRowPath(
172 tableElement: TTableElement,
173 tablePath: Path,
174 ): Path | undefined {
175 if (tableElement.children.length === 0) return undefined;
176
177 return [...tablePath, tableElement.children.length - 1];
178 }
179
180 function getLastTableCellPath(
181 tableElement: TTableElement,
182 tablePath: Path,
183 ): Path | undefined {
184 for (
185 let rowIndex = tableElement.children.length - 1;
186 rowIndex >= 0;
187 rowIndex -= 1
188 ) {
189 const row = tableElement.children[rowIndex];
190
191 if (!ElementApi.isElement(row)) continue;
192
193 if (row.children.length > 0) {
194 return [...tablePath, rowIndex, row.children.length - 1];
195 }
196 }
197
198 return undefined;
199 }
200
201 const PresentationTableElementWithProvider = withHOC(
202 TableProvider,
203 function PresentationTableElement({
204 children,
205 ...props
206 }: PlateElementProps<TTableElement>) {
207 const editor = useEditorRef();
208 const { tf } = useEditorPlugin(TablePlugin);
209 const readOnly = useReadOnly();
210 const isSelectionAreaVisible = usePluginOption(
211 BlockSelectionPlugin,
212 "isSelectionAreaVisible",
213 );
214 const hasControls = !readOnly && !isSelectionAreaVisible;
215 const { marginLeft, props: tableProps } = useTableElement();
216 const isSelectingCells = isSelectingCell(props.editor);
217 const tableId = props.element.id;
218 const isTableBlockSelected = useBlockSelected(
219 typeof tableId === "string" ? tableId : undefined,
220 );
221 const isSelectionInCurrentTable = useEditorSelector(
222 (currentEditor) => {
223 const tableEntry = currentEditor.api.above({
224 match: { type: KEYS.table },
225 });
226 const [selectedTable] = tableEntry ?? [];
227
228 return typeof tableId === "string" && selectedTable?.id === tableId;
229 },
230 [tableId],
231 );
232 const showTableGrowthControls =
233 hasControls && (isTableBlockSelected || isSelectionInCurrentTable);
234
235 const colSizes = props.element.colSizes ?? [];
236
237 const insertColumnAfter = React.useCallback(() => {
238 const tablePath = editor.api.findPath(props.element);
239 if (!tablePath) return;
240
241 const fromCell = getLastTableCellPath(props.element, tablePath);
242 if (!fromCell) return;
243
244 tf.insert.tableColumn({ fromCell, select: true });
245 editor.tf.focus();
246 }, [editor, props.element, tf]);
247
248 const insertRowAfter = React.useCallback(() => {
249 const tablePath = editor.api.findPath(props.element);
250 if (!tablePath) return;
251
252 const fromRow = getLastTableRowPath(props.element, tablePath);
253 if (!fromRow) return;
254
255 tf.insert.tableRow({ fromRow, select: true });
256 editor.tf.focus();
257 }, [editor, props.element, tf]);
258
259 const content = (
260 <PresentationElement
261 {...props}
262 className={cn(
263 "overflow-x-auto py-5",
264 hasControls && "-ml-2 data-[slot=block-selection]:*:left-2",
265 )}
266 style={{ paddingLeft: marginLeft }}
267 >
268 <div
269 className={cn(
270 "group/table relative w-full bg-transparent",
271 showTableGrowthControls &&
272 "grid grid-cols-[minmax(0,1fr)_1.25rem] grid-rows-[auto_1.25rem]",
273 )}
274 >
275 {isTableBlockSelected && (
276 <div className={blockSelectionVariants()} contentEditable={false} />
277 )}
278 <table
279 className={cn(
280 "mr-0 ml-px table h-px max-w-full table-fixed border-collapse bg-transparent text-(--presentation-text)",
281 isSelectingCells && "selection:bg-transparent",
282 colSizes && colSizes.length > 0 && colSizes?.every((s) => s !== 0)
283 ? "w-fit"
284 : "w-full",
285 )}
286 {...tableProps}
287 >
288 <tbody className="w-full">{children}</tbody>
289 </table>
290 {showTableGrowthControls && (
291 <>
292 <Button
293 aria-label="Add column"
294 className={cn(
295 "z-40 h-full min-h-10 w-5 rounded-md border-border/80 bg-muted/80 p-0 text-muted-foreground shadow-xs backdrop-blur-sm",
296 "transition-colors duration-100 hover:bg-background hover:text-foreground",
297 )}
298 contentEditable={false}
299 onClick={insertColumnAfter}
300 onMouseDown={(event) => event.preventDefault()}
301 size="icon"
302 title="Add column"
303 type="button"
304 variant="outline"
305 >
306 <Plus className="size-3.5" data-ppt-ignore="true" />
307 </Button>
308 <Button
309 aria-label="Add row"
310 className={cn(
311 "z-40 col-span-2 h-5 w-auto rounded-md border-border/80 bg-muted/80 p-0 text-muted-foreground shadow-xs backdrop-blur-sm",
312 "transition-colors duration-100 hover:bg-background hover:text-foreground",
313 )}
314 contentEditable={false}
315 onClick={insertRowAfter}
316 onMouseDown={(event) => event.preventDefault()}
317 size="icon"
318 title="Add row"
319 type="button"
320 variant="outline"
321 >
322 <Plus className="size-3.5" data-ppt-ignore="true" />
323 </Button>
324 </>
325 )}
326 </div>
327 </PresentationElement>
328 );
329
330 return content;
331 },
332 );
333
334 export function PresentationTableElement(
335 props: PlateElementProps<TTableElement>,
336 ) {
337 return <PresentationTableElementWithProvider {...props} />;
338 }
339
340 export function PresentationTableRowElement(
341 props: PlateElementProps<TTableRowElement>,
342 ) {
343 const { element } = props;
344 const readOnly = useReadOnly();
345 const editor = useEditorRef();
346 const isSelectionAreaVisible = usePluginOption(
347 BlockSelectionPlugin,
348 "isSelectionAreaVisible",
349 );
350 const hasControls = !readOnly && !isSelectionAreaVisible;
351
352 const { isDragging, nodeRef, handleRef } = useDraggable<HTMLTableRowElement>({
353 element,
354 type: TABLE_ROW_DRAG_TYPE,
355 drag: {
356 disableFreeformDrag: true,
357 },
358 preview: { disable: true },
359 drop: {
360 hover: (dragItem, monitor) => {
361 const direction = getRowDropDirection(monitor, nodeRef.current);
362
363 if (!direction || !canDropTableRow(editor, dragItem, element)) {
364 updateRowDropLine(editor, element, undefined);
365 return;
366 }
367
368 updateRowDropLine(editor, element, direction);
369 },
370 },
371 onDropHandler: (_, { dragItem, monitor }) => {
372 const direction = getRowDropDirection(monitor, nodeRef.current);
373 if (!direction) return true;
374
375 const moved = moveTableRow(editor, dragItem, element, direction);
376 if (moved) editor.tf.focus();
377
378 updateRowDropLine(editor, element, undefined);
379 return true;
380 },
381 });
382 const rowRef = React.useCallback(
383 (node: HTMLTableRowElement | null) => {
384 setRefValue<HTMLTableRowElement>(props.ref, node);
385 setRefValue<HTMLTableRowElement>(nodeRef, node);
386 },
387 [nodeRef, props.ref],
388 );
389
390 return (
391 <PlateElement
392 {...props}
393 ref={rowRef}
394 as="tr"
395 className={cn("group/row", isDragging && "opacity-50")}
396 >
397 {hasControls && (
398 <td className="w-2 select-none" contentEditable={false}>
399 <RowDragHandle dragRef={handleRef} />
400 <RowDropLine />
401 </td>
402 )}
403
404 {props.children}
405 </PlateElement>
406 );
407 }
408
409 function RowDragHandle({ dragRef }: { dragRef: React.Ref<HTMLButtonElement> }) {
410 const editor = useEditorRef();
411 const element = useElement();
412
413 return (
414 <button
415 ref={dragRef}
416 type="button"
417 className={cn(
418 "absolute top-1/2 z-51 w-4 -translate-y-1/2 p-0.5 focus-visible:ring-0 focus-visible:ring-offset-0",
419 "cursor-grab active:cursor-grabbing",
420 'flex items-center rounded bg-accent opacity-0 outline transition-opacity duration-100 group-hover/row:opacity-100 group-has-data-[resizing="true"]/row:opacity-0',
421 )}
422 onClick={() => {
423 editor.tf.select(element);
424 }}
425 >
426 <GripVertical
427 className="size-3.5 text-muted-foreground"
428 data-ppt-ignore="true"
429 />
430 </button>
431 );
432 }
433
434 function RowDropLine() {
435 const { dropLine } = useDropLine();
436
437 if (!dropLine) return null;
438
439 return (
440 <div
441 className={cn(
442 "absolute inset-x-0 left-2 z-50 h-0.5 rounded-full bg-brand/50",
443 dropLine === "top" ? "-top-px" : "-bottom-px",
444 )}
445 />
446 );
447 }
448
449 export function PresentationTableCellElement({
450 isHeader,
451 ...props
452 }: PlateElementProps<TTableCellElement> & {
453 isHeader?: boolean;
454 }) {
455 const { api } = useEditorPlugin(TablePlugin);
456 const readOnly = useReadOnly();
457 const element = props.element;
458
459 const rowId = useElementSelector(([node]) => node.id as string, [], {
460 key: KEYS.tr,
461 });
462 const isSelectingRow = useBlockSelected(rowId);
463 const isSelectionAreaVisible = usePluginOption(
464 BlockSelectionPlugin,
465 "isSelectionAreaVisible",
466 );
467 const selectedCellIds = usePluginOption(TablePlugin, "selectedCellIds");
468
469 const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } =
470 useTableCellElement();
471 const isCellSelected =
472 selected ||
473 (typeof element.id === "string" &&
474 (selectedCellIds?.includes(element.id) ?? false));
475
476 const { bottomProps, hiddenLeft, leftProps, rightProps } =
477 useTableCellElementResizable({
478 colIndex,
479 colSpan,
480 rowIndex,
481 });
482
483 return (
484 <PlateElement
485 {...props}
486 as={isHeader ? "th" : "td"}
487 className={cn(
488 "relative h-full overflow-visible border-none bg-transparent p-0",
489 isHeader && "text-left *:m-0",
490 "before:inset-0 before:z-10 before:size-full",
491 (isCellSelected || isSelectingRow) && "before:bg-brand/20",
492 "before:absolute before:box-border before:content-[''] before:select-none",
493 borders.bottom?.size && `before:border-b before:border-b-border`,
494 borders.right?.size && `before:border-r before:border-r-border`,
495 borders.left?.size && `before:border-l before:border-l-border`,
496 borders.top?.size && `before:border-t before:border-t-border`,
497 )}
498 style={
499 {
500 "--cellBackground": element.background,
501 maxWidth: width || 240,
502 width: width || undefined,
503 minWidth: width || 120,
504 backgroundColor:
505 element.background ??
506 (isHeader ? "var(--presentation-card-background)" : undefined),
507 } as React.CSSProperties
508 }
509 attributes={{
510 ...props.attributes,
511 colSpan: api.table.getColSpan(element),
512 rowSpan: api.table.getRowSpan(element),
513 }}
514 >
515 {(isCellSelected || isSelectingRow) && (
516 <div
517 className="pointer-events-none absolute inset-0 z-10 bg-brand/20"
518 contentEditable={false}
519 />
520 )}
521
522 <div
523 className={cn(
524 "relative z-20 box-border h-full rounded-md px-3 py-2",
525 isHeader ? "text-lg font-bold text-primary" : "presentation-text",
526 )}
527 style={{ minHeight }}
528 >
529 {props.children}
530 </div>
531
532 {!isSelectionAreaVisible && (
533 <div
534 className="group absolute top-0 size-full select-none"
535 contentEditable={false}
536 suppressContentEditableWarning={true}
537 >
538 {!readOnly && (
539 <>
540 <ResizeHandle
541 {...rightProps}
542 className="-top-2 -right-1 h-[calc(100%+8px)] w-2"
543 data-col={colIndex}
544 />
545 <ResizeHandle {...bottomProps} className="-bottom-1 h-2" />
546 {!hiddenLeft && (
547 <ResizeHandle
548 {...leftProps}
549 className="top-0 -left-1 w-2"
550 data-resizer-left={colIndex === 0 ? "true" : undefined}
551 />
552 )}
553
554 <div
555 className={cn(
556 "absolute top-0 z-30 hidden h-full w-1 bg-ring",
557 "right-[-1.5px]",
558 getColumnResizeClass(colIndex),
559 )}
560 />
561 {colIndex === 0 && (
562 <div
563 className={cn(
564 "absolute top-0 z-30 h-full w-1 bg-ring",
565 "left-[-1.5px]",
566 'hidden animate-in fade-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block',
567 )}
568 />
569 )}
570 </>
571 )}
572 </div>
573 )}
574
575 {isSelectingRow && (
576 <div className={blockSelectionVariants()} contentEditable={false} />
577 )}
578 </PlateElement>
579 );
580 }
581
582 export function PresentationTableCellHeaderElement(
583 props: React.ComponentProps<typeof PresentationTableCellElement>,
584 ) {
585 return <PresentationTableCellElement {...props} isHeader />;
586 }
587
588 const COLUMN_RESIZE_CLASSES: Record<number, string> = {
589 0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block',
590 1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block',
591 2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block',
592 3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block',
593 4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block',
594 5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block',
595 6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block',
596 7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block',
597 8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block',
598 9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block',
599 10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block',
600 };
601
602 function getColumnResizeClass(colIndex: number) {
603 return COLUMN_RESIZE_CLASSES[colIndex] ?? "";
604 }
605
605 lines Plain Text