318 lines
10 KiB
TypeScript
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'en-tête
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderColumn().run()}>
|
|
Colonne d'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)
|