| 1 | "use client"; |
| 2 | |
| 3 | import { useDraggable, useDropLine } from "@platejs/dnd"; |
| 4 | import { |
| 5 | BlockSelectionPlugin, |
| 6 | useBlockSelected, |
| 7 | } from "@platejs/selection/react"; |
| 8 | import { isSelectingCell, setCellBackground } from "@platejs/table"; |
| 9 | import { |
| 10 | TablePlugin, |
| 11 | TableProvider, |
| 12 | useTableBordersDropdownMenuContentState, |
| 13 | useTableCellElement, |
| 14 | useTableCellElementResizable, |
| 15 | useTableElement, |
| 16 | useTableMergeState, |
| 17 | } from "@platejs/table/react"; |
| 18 | import type * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; |
| 19 | import { PopoverAnchor } from "@radix-ui/react-popover"; |
| 20 | import { |
| 21 | ArrowDown, |
| 22 | ArrowLeft, |
| 23 | ArrowRight, |
| 24 | ArrowUp, |
| 25 | CombineIcon, |
| 26 | EraserIcon, |
| 27 | Grid2X2Icon, |
| 28 | GripVertical, |
| 29 | PaintBucketIcon, |
| 30 | Plus, |
| 31 | SquareSplitHorizontalIcon, |
| 32 | Trash2Icon, |
| 33 | XIcon, |
| 34 | } from "lucide-react"; |
| 35 | import { |
| 36 | ElementApi, |
| 37 | KEYS, |
| 38 | PathApi, |
| 39 | type Path, |
| 40 | type TTableCellElement, |
| 41 | type TTableElement, |
| 42 | type TTableRowElement, |
| 43 | } from "platejs"; |
| 44 | import { |
| 45 | PlateElement, |
| 46 | useEditorPlugin, |
| 47 | useEditorRef, |
| 48 | useEditorSelector, |
| 49 | useElement, |
| 50 | useElementSelector, |
| 51 | usePluginOption, |
| 52 | useReadOnly, |
| 53 | useRemoveNodeButton, |
| 54 | useSelected, |
| 55 | withHOC, |
| 56 | type PlateElementProps, |
| 57 | } from "platejs/react"; |
| 58 | import * as React from "react"; |
| 59 | |
| 60 | import { Button } from "@/components/plate/ui/button"; |
| 61 | import { |
| 62 | DropdownMenu, |
| 63 | DropdownMenuCheckboxItem, |
| 64 | DropdownMenuContent, |
| 65 | DropdownMenuGroup, |
| 66 | DropdownMenuItem, |
| 67 | DropdownMenuPortal, |
| 68 | DropdownMenuTrigger, |
| 69 | } from "@/components/plate/ui/dropdown-menu"; |
| 70 | import { Popover, PopoverContent } from "@/components/plate/ui/popover"; |
| 71 | import { cn } from "@/lib/utils"; |
| 72 | import { blockSelectionVariants } from "./block-selection"; |
| 73 | import { ColorDropdownMenuItems, DEFAULT_COLORS } from "./font-color-toolbar-button"; |
| 74 | import { ResizeHandle } from "./resize-handle"; |
| 75 | import { |
| 76 | BorderAllIcon, |
| 77 | BorderBottomIcon, |
| 78 | BorderLeftIcon, |
| 79 | BorderNoneIcon, |
| 80 | BorderRightIcon, |
| 81 | BorderTopIcon, |
| 82 | } from "./table-icons"; |
| 83 | import { |
| 84 | Toolbar, |
| 85 | ToolbarButton, |
| 86 | ToolbarGroup, |
| 87 | ToolbarMenuGroup, |
| 88 | } from "./toolbar"; |
| 89 | |
| 90 | function setRefValue<T>(ref: React.Ref<T> | undefined, value: T | null) { |
| 91 | if (typeof ref === "function") { |
| 92 | ref(value); |
| 93 | return; |
| 94 | } |
| 95 | |
| 96 | if (ref) { |
| 97 | ref.current = value; |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | function getLastTableRowPath( |
| 102 | tableElement: TTableElement, |
| 103 | tablePath: Path, |
| 104 | ): Path | undefined { |
| 105 | if (tableElement.children.length === 0) return undefined; |
| 106 | |
| 107 | return [...tablePath, tableElement.children.length - 1]; |
| 108 | } |
| 109 | |
| 110 | function getLastTableCellPath( |
| 111 | tableElement: TTableElement, |
| 112 | tablePath: Path, |
| 113 | ): Path | undefined { |
| 114 | for ( |
| 115 | let rowIndex = tableElement.children.length - 1; |
| 116 | rowIndex >= 0; |
| 117 | rowIndex -= 1 |
| 118 | ) { |
| 119 | const row = tableElement.children[rowIndex]; |
| 120 | |
| 121 | if (!ElementApi.isElement(row)) continue; |
| 122 | |
| 123 | if (row.children.length > 0) { |
| 124 | return [...tablePath, rowIndex, row.children.length - 1]; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | return undefined; |
| 129 | } |
| 130 | |
| 131 | export const TableElement = withHOC( |
| 132 | TableProvider, |
| 133 | function TableElement({ |
| 134 | children, |
| 135 | ...props |
| 136 | }: PlateElementProps<TTableElement>) { |
| 137 | const editor = useEditorRef(); |
| 138 | const { tf } = useEditorPlugin(TablePlugin); |
| 139 | const readOnly = useReadOnly(); |
| 140 | const isSelectionAreaVisible = usePluginOption( |
| 141 | BlockSelectionPlugin, |
| 142 | "isSelectionAreaVisible", |
| 143 | ); |
| 144 | const hasControls = !readOnly && !isSelectionAreaVisible; |
| 145 | const selected = useSelected(); |
| 146 | const { marginLeft, props: tableProps } = useTableElement(); |
| 147 | const isSelectingCells = isSelectingCell(props.editor); |
| 148 | const tableId = props.element.id; |
| 149 | const isTableBlockSelected = useBlockSelected( |
| 150 | typeof tableId === "string" ? tableId : undefined, |
| 151 | ); |
| 152 | const isSelectionInCurrentTable = useEditorSelector( |
| 153 | (currentEditor) => { |
| 154 | const tableEntry = currentEditor.api.above({ |
| 155 | match: { type: KEYS.table }, |
| 156 | }); |
| 157 | const [selectedTable] = tableEntry ?? []; |
| 158 | |
| 159 | return typeof tableId === "string" && selectedTable?.id === tableId; |
| 160 | }, |
| 161 | [tableId], |
| 162 | ); |
| 163 | const showTableGrowthControls = |
| 164 | hasControls && (isTableBlockSelected || isSelectionInCurrentTable); |
| 165 | |
| 166 | const colSizes = props.element.colSizes ?? []; |
| 167 | |
| 168 | const insertColumnAfter = React.useCallback(() => { |
| 169 | const tablePath = editor.api.findPath(props.element); |
| 170 | if (!tablePath) return; |
| 171 | |
| 172 | const fromCell = getLastTableCellPath(props.element, tablePath); |
| 173 | if (!fromCell) return; |
| 174 | |
| 175 | tf.insert.tableColumn({ fromCell, select: true }); |
| 176 | editor.tf.focus(); |
| 177 | }, [editor, props.element, tf]); |
| 178 | |
| 179 | const insertRowAfter = React.useCallback(() => { |
| 180 | const tablePath = editor.api.findPath(props.element); |
| 181 | if (!tablePath) return; |
| 182 | |
| 183 | const fromRow = getLastTableRowPath(props.element, tablePath); |
| 184 | if (!fromRow) return; |
| 185 | |
| 186 | tf.insert.tableRow({ fromRow, select: true }); |
| 187 | editor.tf.focus(); |
| 188 | }, [editor, props.element, tf]); |
| 189 | |
| 190 | const content = ( |
| 191 | <PlateElement |
| 192 | {...props} |
| 193 | className={cn( |
| 194 | "overflow-x-auto py-5", |
| 195 | hasControls && "-ml-2 data-[slot=block-selection]:*:left-2", |
| 196 | )} |
| 197 | style={{ paddingLeft: marginLeft }} |
| 198 | > |
| 199 | <div |
| 200 | className={cn( |
| 201 | "group/table relative w-full bg-transparent", |
| 202 | showTableGrowthControls && |
| 203 | "grid grid-cols-[minmax(0,1fr)_1.25rem] grid-rows-[auto_1.25rem]", |
| 204 | )} |
| 205 | > |
| 206 | {isTableBlockSelected && ( |
| 207 | <div className={blockSelectionVariants()} contentEditable={false} /> |
| 208 | )} |
| 209 | <table |
| 210 | className={cn( |
| 211 | "mr-0 ml-px table h-px max-w-full table-fixed border-collapse bg-transparent text-(--presentation-text)", |
| 212 | isSelectingCells && "selection:bg-transparent", |
| 213 | colSizes.length > 0 && colSizes.every((size) => size !== 0) |
| 214 | ? "w-fit" |
| 215 | : "w-full", |
| 216 | )} |
| 217 | {...tableProps} |
| 218 | > |
| 219 | <tbody className="w-full">{children}</tbody> |
| 220 | </table> |
| 221 | {showTableGrowthControls && ( |
| 222 | <> |
| 223 | <Button |
| 224 | aria-label="Add column" |
| 225 | className={cn( |
| 226 | "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", |
| 227 | "transition-colors duration-100 hover:bg-background hover:text-foreground", |
| 228 | )} |
| 229 | contentEditable={false} |
| 230 | onClick={insertColumnAfter} |
| 231 | onMouseDown={(event) => event.preventDefault()} |
| 232 | size="icon" |
| 233 | title="Add column" |
| 234 | type="button" |
| 235 | variant="outline" |
| 236 | > |
| 237 | <Plus className="size-3.5" data-ppt-ignore="true" /> |
| 238 | </Button> |
| 239 | <Button |
| 240 | aria-label="Add row" |
| 241 | className={cn( |
| 242 | "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", |
| 243 | "transition-colors duration-100 hover:bg-background hover:text-foreground", |
| 244 | )} |
| 245 | contentEditable={false} |
| 246 | onClick={insertRowAfter} |
| 247 | onMouseDown={(event) => event.preventDefault()} |
| 248 | size="icon" |
| 249 | title="Add row" |
| 250 | type="button" |
| 251 | variant="outline" |
| 252 | > |
| 253 | <Plus className="size-3.5" data-ppt-ignore="true" /> |
| 254 | </Button> |
| 255 | </> |
| 256 | )} |
| 257 | </div> |
| 258 | </PlateElement> |
| 259 | ); |
| 260 | |
| 261 | if (readOnly || !selected) { |
| 262 | return content; |
| 263 | } |
| 264 | |
| 265 | return <TableFloatingToolbar>{content}</TableFloatingToolbar>; |
| 266 | }, |
| 267 | ); |
| 268 | |
| 269 | function TableFloatingToolbar({ |
| 270 | children, |
| 271 | ...props |
| 272 | }: React.ComponentProps<typeof PopoverContent>) { |
| 273 | const { tf } = useEditorPlugin(TablePlugin); |
| 274 | const element = useElement<TTableElement>(); |
| 275 | const { props: buttonProps } = useRemoveNodeButton({ element }); |
| 276 | const collapsed = useEditorSelector((editor) => !editor.api.isExpanded(), []); |
| 277 | |
| 278 | const { canMerge, canSplit } = useTableMergeState(); |
| 279 | |
| 280 | return ( |
| 281 | <Popover open={canMerge || canSplit || collapsed} modal={false}> |
| 282 | <PopoverAnchor asChild>{children}</PopoverAnchor> |
| 283 | <PopoverContent |
| 284 | asChild |
| 285 | onOpenAutoFocus={(e) => e.preventDefault()} |
| 286 | contentEditable={false} |
| 287 | {...props} |
| 288 | > |
| 289 | <Toolbar |
| 290 | className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden" |
| 291 | contentEditable={false} |
| 292 | > |
| 293 | <ToolbarGroup> |
| 294 | <ColorDropdownMenu tooltip="Background color"> |
| 295 | <PaintBucketIcon /> |
| 296 | </ColorDropdownMenu> |
| 297 | {canMerge && ( |
| 298 | <ToolbarButton |
| 299 | onClick={() => tf.table.merge()} |
| 300 | onMouseDown={(e) => e.preventDefault()} |
| 301 | tooltip="Merge cells" |
| 302 | > |
| 303 | <CombineIcon /> |
| 304 | </ToolbarButton> |
| 305 | )} |
| 306 | {canSplit && ( |
| 307 | <ToolbarButton |
| 308 | onClick={() => tf.table.split()} |
| 309 | onMouseDown={(e) => e.preventDefault()} |
| 310 | tooltip="Split cell" |
| 311 | > |
| 312 | <SquareSplitHorizontalIcon /> |
| 313 | </ToolbarButton> |
| 314 | )} |
| 315 | |
| 316 | <DropdownMenu modal={false}> |
| 317 | <DropdownMenuTrigger asChild> |
| 318 | <ToolbarButton tooltip="Cell borders"> |
| 319 | <Grid2X2Icon /> |
| 320 | </ToolbarButton> |
| 321 | </DropdownMenuTrigger> |
| 322 | |
| 323 | <DropdownMenuPortal> |
| 324 | <TableBordersDropdownMenuContent /> |
| 325 | </DropdownMenuPortal> |
| 326 | </DropdownMenu> |
| 327 | |
| 328 | {collapsed && ( |
| 329 | <ToolbarGroup> |
| 330 | <ToolbarButton tooltip="Delete table" {...buttonProps}> |
| 331 | <Trash2Icon /> |
| 332 | </ToolbarButton> |
| 333 | </ToolbarGroup> |
| 334 | )} |
| 335 | </ToolbarGroup> |
| 336 | |
| 337 | {collapsed && ( |
| 338 | <ToolbarGroup> |
| 339 | <ToolbarButton |
| 340 | onClick={() => { |
| 341 | tf.insert.tableRow({ before: true }); |
| 342 | }} |
| 343 | onMouseDown={(e) => e.preventDefault()} |
| 344 | tooltip="Insert row before" |
| 345 | > |
| 346 | <ArrowUp /> |
| 347 | </ToolbarButton> |
| 348 | <ToolbarButton |
| 349 | onClick={() => { |
| 350 | tf.insert.tableRow(); |
| 351 | }} |
| 352 | onMouseDown={(e) => e.preventDefault()} |
| 353 | tooltip="Insert row after" |
| 354 | > |
| 355 | <ArrowDown /> |
| 356 | </ToolbarButton> |
| 357 | <ToolbarButton |
| 358 | onClick={() => { |
| 359 | tf.remove.tableRow(); |
| 360 | }} |
| 361 | onMouseDown={(e) => e.preventDefault()} |
| 362 | tooltip="Delete row" |
| 363 | > |
| 364 | <XIcon /> |
| 365 | </ToolbarButton> |
| 366 | </ToolbarGroup> |
| 367 | )} |
| 368 | |
| 369 | {collapsed && ( |
| 370 | <ToolbarGroup> |
| 371 | <ToolbarButton |
| 372 | onClick={() => { |
| 373 | tf.insert.tableColumn({ before: true }); |
| 374 | }} |
| 375 | onMouseDown={(e) => e.preventDefault()} |
| 376 | tooltip="Insert column before" |
| 377 | > |
| 378 | <ArrowLeft /> |
| 379 | </ToolbarButton> |
| 380 | <ToolbarButton |
| 381 | onClick={() => { |
| 382 | tf.insert.tableColumn(); |
| 383 | }} |
| 384 | onMouseDown={(e) => e.preventDefault()} |
| 385 | tooltip="Insert column after" |
| 386 | > |
| 387 | <ArrowRight /> |
| 388 | </ToolbarButton> |
| 389 | <ToolbarButton |
| 390 | onClick={() => { |
| 391 | tf.remove.tableColumn(); |
| 392 | }} |
| 393 | onMouseDown={(e) => e.preventDefault()} |
| 394 | tooltip="Delete column" |
| 395 | > |
| 396 | <XIcon /> |
| 397 | </ToolbarButton> |
| 398 | </ToolbarGroup> |
| 399 | )} |
| 400 | </Toolbar> |
| 401 | </PopoverContent> |
| 402 | </Popover> |
| 403 | ); |
| 404 | } |
| 405 | |
| 406 | function TableBordersDropdownMenuContent( |
| 407 | props: React.ComponentProps<typeof DropdownMenuPrimitive.Content>, |
| 408 | ) { |
| 409 | const editor = useEditorRef(); |
| 410 | const { |
| 411 | getOnSelectTableBorder, |
| 412 | hasBottomBorder, |
| 413 | hasLeftBorder, |
| 414 | hasNoBorders, |
| 415 | hasOuterBorders, |
| 416 | hasRightBorder, |
| 417 | hasTopBorder, |
| 418 | } = useTableBordersDropdownMenuContentState(); |
| 419 | |
| 420 | return ( |
| 421 | <DropdownMenuContent |
| 422 | className="min-w-55" |
| 423 | onCloseAutoFocus={(e) => { |
| 424 | e.preventDefault(); |
| 425 | editor.tf.focus(); |
| 426 | }} |
| 427 | align="start" |
| 428 | side="right" |
| 429 | sideOffset={0} |
| 430 | {...props} |
| 431 | > |
| 432 | <DropdownMenuGroup> |
| 433 | <DropdownMenuCheckboxItem |
| 434 | checked={hasTopBorder} |
| 435 | onCheckedChange={getOnSelectTableBorder("top")} |
| 436 | > |
| 437 | <BorderTopIcon /> |
| 438 | <div>Top Border</div> |
| 439 | </DropdownMenuCheckboxItem> |
| 440 | <DropdownMenuCheckboxItem |
| 441 | checked={hasRightBorder} |
| 442 | onCheckedChange={getOnSelectTableBorder("right")} |
| 443 | > |
| 444 | <BorderRightIcon /> |
| 445 | <div>Right Border</div> |
| 446 | </DropdownMenuCheckboxItem> |
| 447 | <DropdownMenuCheckboxItem |
| 448 | checked={hasBottomBorder} |
| 449 | onCheckedChange={getOnSelectTableBorder("bottom")} |
| 450 | > |
| 451 | <BorderBottomIcon /> |
| 452 | <div>Bottom Border</div> |
| 453 | </DropdownMenuCheckboxItem> |
| 454 | <DropdownMenuCheckboxItem |
| 455 | checked={hasLeftBorder} |
| 456 | onCheckedChange={getOnSelectTableBorder("left")} |
| 457 | > |
| 458 | <BorderLeftIcon /> |
| 459 | <div>Left Border</div> |
| 460 | </DropdownMenuCheckboxItem> |
| 461 | </DropdownMenuGroup> |
| 462 | |
| 463 | <DropdownMenuGroup> |
| 464 | <DropdownMenuCheckboxItem |
| 465 | checked={hasNoBorders} |
| 466 | onCheckedChange={getOnSelectTableBorder("none")} |
| 467 | > |
| 468 | <BorderNoneIcon /> |
| 469 | <div>No Border</div> |
| 470 | </DropdownMenuCheckboxItem> |
| 471 | <DropdownMenuCheckboxItem |
| 472 | checked={hasOuterBorders} |
| 473 | onCheckedChange={getOnSelectTableBorder("outer")} |
| 474 | > |
| 475 | <BorderAllIcon /> |
| 476 | <div>Outside Borders</div> |
| 477 | </DropdownMenuCheckboxItem> |
| 478 | </DropdownMenuGroup> |
| 479 | </DropdownMenuContent> |
| 480 | ); |
| 481 | } |
| 482 | |
| 483 | function ColorDropdownMenu({ |
| 484 | children, |
| 485 | tooltip, |
| 486 | }: { |
| 487 | children: React.ReactNode; |
| 488 | tooltip: string; |
| 489 | }) { |
| 490 | const [open, setOpen] = React.useState(false); |
| 491 | |
| 492 | const editor = useEditorRef(); |
| 493 | const selectedCells = usePluginOption(TablePlugin, "selectedCells"); |
| 494 | |
| 495 | const onUpdateColor = React.useCallback( |
| 496 | (color: string) => { |
| 497 | setOpen(false); |
| 498 | setCellBackground(editor, { color, selectedCells: selectedCells ?? [] }); |
| 499 | }, |
| 500 | [selectedCells, editor], |
| 501 | ); |
| 502 | |
| 503 | const onClearColor = React.useCallback(() => { |
| 504 | setOpen(false); |
| 505 | setCellBackground(editor, { |
| 506 | color: null, |
| 507 | selectedCells: selectedCells ?? [], |
| 508 | }); |
| 509 | }, [selectedCells, editor]); |
| 510 | |
| 511 | return ( |
| 512 | <DropdownMenu open={open} onOpenChange={setOpen} modal={false}> |
| 513 | <DropdownMenuTrigger asChild> |
| 514 | <ToolbarButton tooltip={tooltip}>{children}</ToolbarButton> |
| 515 | </DropdownMenuTrigger> |
| 516 | |
| 517 | <DropdownMenuContent align="start"> |
| 518 | <ToolbarMenuGroup label="Colors"> |
| 519 | <ColorDropdownMenuItems |
| 520 | className="px-2" |
| 521 | colors={DEFAULT_COLORS} |
| 522 | updateColor={onUpdateColor} |
| 523 | /> |
| 524 | </ToolbarMenuGroup> |
| 525 | <DropdownMenuGroup> |
| 526 | <DropdownMenuItem className="p-2" onClick={onClearColor}> |
| 527 | <EraserIcon /> |
| 528 | <span>Clear</span> |
| 529 | </DropdownMenuItem> |
| 530 | </DropdownMenuGroup> |
| 531 | </DropdownMenuContent> |
| 532 | </DropdownMenu> |
| 533 | ); |
| 534 | } |
| 535 | |
| 536 | export function TableRowElement(props: PlateElementProps<TTableRowElement>) { |
| 537 | const { element } = props; |
| 538 | const readOnly = useReadOnly(); |
| 539 | const editor = useEditorRef(); |
| 540 | const isSelectionAreaVisible = usePluginOption( |
| 541 | BlockSelectionPlugin, |
| 542 | "isSelectionAreaVisible", |
| 543 | ); |
| 544 | const hasControls = !readOnly && !isSelectionAreaVisible; |
| 545 | |
| 546 | const { isDragging, previewRef, handleRef } = useDraggable({ |
| 547 | element, |
| 548 | type: element.type, |
| 549 | canDropNode: ({ dragEntry, dropEntry }) => |
| 550 | PathApi.equals( |
| 551 | PathApi.parent(dragEntry[1]), |
| 552 | PathApi.parent(dropEntry[1]), |
| 553 | ), |
| 554 | onDropHandler: (_, { dragItem }) => { |
| 555 | const dragElement = |
| 556 | "element" in dragItem && ElementApi.isElement(dragItem.element) |
| 557 | ? dragItem.element |
| 558 | : undefined; |
| 559 | |
| 560 | if (dragElement) { |
| 561 | editor.tf.select(dragElement); |
| 562 | } |
| 563 | }, |
| 564 | }); |
| 565 | const rowRef = React.useCallback( |
| 566 | (node: HTMLTableRowElement | null) => { |
| 567 | setRefValue<HTMLElement>(props.ref, node); |
| 568 | setRefValue<HTMLElement>(previewRef, node); |
| 569 | }, |
| 570 | [previewRef, props.ref], |
| 571 | ); |
| 572 | |
| 573 | return ( |
| 574 | <PlateElement |
| 575 | {...props} |
| 576 | ref={rowRef} |
| 577 | as="tr" |
| 578 | className={cn("group/row", isDragging && "opacity-50")} |
| 579 | > |
| 580 | {hasControls && ( |
| 581 | <td className="w-2 select-none" contentEditable={false}> |
| 582 | <RowDragHandle dragRef={handleRef} /> |
| 583 | <RowDropLine /> |
| 584 | </td> |
| 585 | )} |
| 586 | |
| 587 | {props.children} |
| 588 | </PlateElement> |
| 589 | ); |
| 590 | } |
| 591 | |
| 592 | function RowDragHandle({ dragRef }: { dragRef: React.Ref<HTMLButtonElement> }) { |
| 593 | const editor = useEditorRef(); |
| 594 | const element = useElement(); |
| 595 | |
| 596 | return ( |
| 597 | <button |
| 598 | ref={dragRef} |
| 599 | type="button" |
| 600 | className={cn( |
| 601 | "absolute top-1/2 z-51 w-4 -translate-y-1/2 p-0.5 focus-visible:ring-0 focus-visible:ring-offset-0", |
| 602 | "cursor-grab active:cursor-grabbing", |
| 603 | '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', |
| 604 | )} |
| 605 | onClick={() => { |
| 606 | editor.tf.select(element); |
| 607 | }} |
| 608 | > |
| 609 | <GripVertical |
| 610 | className="size-3.5 text-muted-foreground" |
| 611 | data-ppt-ignore="true" |
| 612 | /> |
| 613 | </button> |
| 614 | ); |
| 615 | } |
| 616 | |
| 617 | function RowDropLine() { |
| 618 | const { dropLine } = useDropLine(); |
| 619 | |
| 620 | if (!dropLine) return null; |
| 621 | |
| 622 | return ( |
| 623 | <div |
| 624 | className={cn( |
| 625 | "absolute inset-x-0 left-2 z-50 h-0.5 rounded-full bg-brand/50", |
| 626 | dropLine === "top" ? "-top-px" : "-bottom-px", |
| 627 | )} |
| 628 | /> |
| 629 | ); |
| 630 | } |
| 631 | |
| 632 | export function TableCellElement({ |
| 633 | isHeader, |
| 634 | ...props |
| 635 | }: PlateElementProps<TTableCellElement> & { |
| 636 | isHeader?: boolean; |
| 637 | }) { |
| 638 | const { api } = useEditorPlugin(TablePlugin); |
| 639 | const readOnly = useReadOnly(); |
| 640 | const element = props.element; |
| 641 | |
| 642 | const rowId = useElementSelector(([node]) => node.id as string, [], { |
| 643 | key: KEYS.tr, |
| 644 | }); |
| 645 | const isSelectingRow = useBlockSelected(rowId); |
| 646 | const isSelectionAreaVisible = usePluginOption( |
| 647 | BlockSelectionPlugin, |
| 648 | "isSelectionAreaVisible", |
| 649 | ); |
| 650 | const selectedCellIds = usePluginOption(TablePlugin, "selectedCellIds"); |
| 651 | |
| 652 | const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } = |
| 653 | useTableCellElement(); |
| 654 | const isCellSelected = |
| 655 | selected || |
| 656 | (typeof element.id === "string" && |
| 657 | (selectedCellIds?.includes(element.id) ?? false)); |
| 658 | |
| 659 | const { bottomProps, hiddenLeft, leftProps, rightProps } = |
| 660 | useTableCellElementResizable({ |
| 661 | colIndex, |
| 662 | colSpan, |
| 663 | rowIndex, |
| 664 | }); |
| 665 | |
| 666 | return ( |
| 667 | <PlateElement |
| 668 | {...props} |
| 669 | as={isHeader ? "th" : "td"} |
| 670 | className={cn( |
| 671 | "relative h-full overflow-visible border-none bg-transparent p-0", |
| 672 | isHeader && "text-left *:m-0", |
| 673 | "before:inset-0 before:z-10 before:size-full", |
| 674 | (isCellSelected || isSelectingRow) && "before:bg-brand/20", |
| 675 | "before:absolute before:box-border before:content-[''] before:select-none", |
| 676 | borders.bottom?.size && `before:border-b before:border-b-border`, |
| 677 | borders.right?.size && `before:border-r before:border-r-border`, |
| 678 | borders.left?.size && `before:border-l before:border-l-border`, |
| 679 | borders.top?.size && `before:border-t before:border-t-border`, |
| 680 | )} |
| 681 | style={ |
| 682 | { |
| 683 | "--cellBackground": element.background, |
| 684 | maxWidth: width || 240, |
| 685 | width: width || undefined, |
| 686 | minWidth: width || 120, |
| 687 | backgroundColor: |
| 688 | element.background ?? |
| 689 | (isHeader ? "var(--presentation-card-background)" : undefined), |
| 690 | } as React.CSSProperties |
| 691 | } |
| 692 | attributes={{ |
| 693 | ...props.attributes, |
| 694 | colSpan: api.table.getColSpan(element), |
| 695 | rowSpan: api.table.getRowSpan(element), |
| 696 | }} |
| 697 | > |
| 698 | {(isCellSelected || isSelectingRow) && ( |
| 699 | <div |
| 700 | className="pointer-events-none absolute inset-0 z-10 bg-brand/20" |
| 701 | contentEditable={false} |
| 702 | /> |
| 703 | )} |
| 704 | |
| 705 | <div |
| 706 | className={cn( |
| 707 | "relative z-20 box-border h-full rounded-md px-3 py-2", |
| 708 | isHeader ? "text-lg font-bold text-primary" : "presentation-text", |
| 709 | )} |
| 710 | style={{ minHeight }} |
| 711 | > |
| 712 | {props.children} |
| 713 | </div> |
| 714 | |
| 715 | {!isSelectionAreaVisible && ( |
| 716 | <div |
| 717 | className="group absolute top-0 size-full select-none" |
| 718 | contentEditable={false} |
| 719 | suppressContentEditableWarning={true} |
| 720 | > |
| 721 | {!readOnly && ( |
| 722 | <> |
| 723 | <ResizeHandle |
| 724 | {...rightProps} |
| 725 | className="-top-2 -right-1 h-[calc(100%+8px)] w-2" |
| 726 | data-col={colIndex} |
| 727 | /> |
| 728 | <ResizeHandle {...bottomProps} className="-bottom-1 h-2" /> |
| 729 | {!hiddenLeft && ( |
| 730 | <ResizeHandle |
| 731 | {...leftProps} |
| 732 | className="top-0 -left-1 w-2" |
| 733 | data-resizer-left={colIndex === 0 ? "true" : undefined} |
| 734 | /> |
| 735 | )} |
| 736 | |
| 737 | <div |
| 738 | className={cn( |
| 739 | "absolute top-0 z-30 hidden h-full w-1 bg-ring", |
| 740 | "right-[-1.5px]", |
| 741 | getColumnResizeClass(colIndex), |
| 742 | )} |
| 743 | /> |
| 744 | {colIndex === 0 && ( |
| 745 | <div |
| 746 | className={cn( |
| 747 | "absolute top-0 z-30 h-full w-1 bg-ring", |
| 748 | "left-[-1.5px]", |
| 749 | 'hidden animate-in fade-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block', |
| 750 | )} |
| 751 | /> |
| 752 | )} |
| 753 | </> |
| 754 | )} |
| 755 | </div> |
| 756 | )} |
| 757 | |
| 758 | {isSelectingRow && ( |
| 759 | <div className={blockSelectionVariants()} contentEditable={false} /> |
| 760 | )} |
| 761 | </PlateElement> |
| 762 | ); |
| 763 | } |
| 764 | |
| 765 | export function TableCellHeaderElement( |
| 766 | props: React.ComponentProps<typeof TableCellElement>, |
| 767 | ) { |
| 768 | return <TableCellElement {...props} isHeader />; |
| 769 | } |
| 770 | |
| 771 | const COLUMN_RESIZE_CLASSES: Record<number, string> = { |
| 772 | 0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block', |
| 773 | 1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block', |
| 774 | 2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block', |
| 775 | 3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block', |
| 776 | 4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block', |
| 777 | 5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block', |
| 778 | 6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block', |
| 779 | 7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block', |
| 780 | 8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block', |
| 781 | 9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block', |
| 782 | 10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block', |
| 783 | }; |
| 784 | |
| 785 | function getColumnResizeClass(colIndex: number) { |
| 786 | return COLUMN_RESIZE_CLASSES[colIndex] ?? ""; |
| 787 | } |
| 788 |