ultisuite-client/components/drive/richtext/docs-table-floating-toolbar.tsx
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

318 lines
10 KiB
TypeScript

"use client"
import { memo, useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import type { Editor } from "@tiptap/react"
import { Icon } from "@iconify/react"
import {
ArrowDownToLine,
ArrowUpToLine,
Columns2,
Merge,
PaintBucket,
Rows2,
Split,
Square,
Trash2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
docsClearTableCellBorders,
docsDefaultTableBorder,
docsSetTableCellBackground,
docsSetTableCellBordersAll,
docsSetTableCellVerticalAlign,
docsTableActive,
docsTableCanMerge,
docsTableCanSplit,
} from "@/lib/drive/docs-table-actions"
import {
DOCS_TABLE_BORDER_COLOR_PRESETS,
DOCS_TABLE_CELL_BACKGROUND_PRESETS,
} from "@/lib/drive/docs-table-types"
import { cn } from "@/lib/utils"
function ToolbarDivider() {
return <span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
}
function IconToolbarButton({
label,
disabled,
onClick,
children,
}: {
label: string
disabled?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full text-popover-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
)
}
function DocsTableFloatingToolbarInner({
editor,
canvasRef,
disabled,
}: {
editor: Editor | null
canvasRef: React.RefObject<HTMLElement | null>
disabled?: boolean
}) {
const [rect, setRect] = useState<DOMRect | null>(null)
const [, setTick] = useState(0)
const refresh = useCallback(() => {
setTick((value) => value + 1)
if (!editor || !docsTableActive(editor)) {
setRect(null)
return
}
const selectedCell = editor.view.dom.querySelector(
"td.selectedCell, th.selectedCell, .ProseMirror-selectednode table"
) as HTMLElement | null
const table =
selectedCell?.closest("table") ??
(editor.view.dom.querySelector(".ProseMirror-selectednode table") as HTMLElement | null)
if (!table) {
setRect(null)
return
}
setRect(table.getBoundingClientRect())
}, [editor])
useEffect(() => {
if (!editor) return
refresh()
editor.on("selectionUpdate", refresh)
editor.on("transaction", refresh)
const canvas = canvasRef.current
canvas?.addEventListener("scroll", refresh, { passive: true })
window.addEventListener("resize", refresh)
return () => {
editor.off("selectionUpdate", refresh)
editor.off("transaction", refresh)
canvas?.removeEventListener("scroll", refresh)
window.removeEventListener("resize", refresh)
}
}, [canvasRef, editor, refresh])
if (!editor || disabled || !docsTableActive(editor) || !rect) return null
const canMerge = docsTableCanMerge(editor)
const canSplit = docsTableCanSplit(editor)
const toolbar = (
<div
className="docs-table-floating-toolbar pointer-events-auto fixed z-200 -translate-x-1/2"
style={{ left: rect.left + rect.width / 2, top: Math.max(8, rect.top - 44) }}
role="toolbar"
aria-label="Options de tableau"
>
<div className="flex items-center gap-0.5 rounded-full border border-border bg-popover px-1 py-0.5 text-popover-foreground shadow-md">
<IconToolbarButton
label="Insérer une ligne au-dessus"
onClick={() => editor.chain().focus().addRowBefore().run()}
>
<ArrowUpToLine className="size-3.5" />
</IconToolbarButton>
<IconToolbarButton
label="Insérer une ligne en dessous"
onClick={() => editor.chain().focus().addRowAfter().run()}
>
<ArrowDownToLine className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<IconToolbarButton
label="Insérer une colonne à gauche"
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
<Columns2 className="size-3.5 -scale-x-100" />
</IconToolbarButton>
<IconToolbarButton
label="Insérer une colonne à droite"
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
<Columns2 className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<IconToolbarButton
label="Fusionner les cellules"
disabled={!canMerge}
onClick={() => editor.chain().focus().mergeCells().run()}
>
<Merge className="size-3.5" />
</IconToolbarButton>
<IconToolbarButton
label="Scinder la cellule"
disabled={!canSplit}
onClick={() => editor.chain().focus().splitCell().run()}
>
<Split className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Bordures de cellule"
title="Bordures de cellule"
>
<Square className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-48">
<DropdownMenuItem
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder("#000000"))
}
>
Bordures noires
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsClearTableCellBorders(editor)}>
Supprimer les bordures
</DropdownMenuItem>
<DropdownMenuSeparator />
{DOCS_TABLE_BORDER_COLOR_PRESETS.slice(0, 6).map((color) => (
<DropdownMenuItem
key={color}
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder(color))
}
>
<span
className="mr-2 inline-block size-4 rounded-sm border border-border"
style={{ backgroundColor: color }}
aria-hidden
/>
{color}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Couleur de cellule"
title="Couleur de cellule"
>
<PaintBucket className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-44">
{DOCS_TABLE_CELL_BACKGROUND_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.id || "none"}
onClick={() => docsSetTableCellBackground(editor, preset.color || null)}
>
<span
className="mr-2 inline-block size-4 rounded-sm border border-border"
style={{ backgroundColor: preset.color || "transparent" }}
aria-hidden
/>
{preset.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Alignement vertical"
title="Alignement vertical"
>
<Rows2 className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "top")}>
Haut
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "middle")}>
Milieu
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "bottom")}>
Bas
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-8 shrink-0 rounded-full")}
aria-label="Plus d'options"
title="Plus d'options"
>
<Icon icon="material-symbols:more-horiz" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderRow().run()}>
Ligne d&apos;en-tête
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderColumn().run()}>
Colonne d&apos;en-tête
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => editor.chain().focus().deleteRow().run()}>
Supprimer la ligne
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editor.chain().focus().deleteColumn().run()}>
Supprimer la colonne
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => editor.chain().focus().deleteTable().run()}
>
<Trash2 className="mr-2 size-4" />
Supprimer le tableau
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
return createPortal(toolbar, document.body)
}
export const DocsTableFloatingToolbar = memo(DocsTableFloatingToolbarInner)