ubiquitous-invention/apps/web/components/editor/toolbar.tsx

345 lines
9.7 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import type { ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Highlighter,
ChevronDown,
List,
ListOrdered,
ListTodo,
AlignLeft,
AlignCenter,
AlignRight,
ImageIcon,
Minus,
Table2,
Undo2,
Redo2,
Heading1,
Heading2,
Heading3,
Pilcrow,
SquareCode,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function ch(editor: Editor) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus() as any;
}
type EditorToolbarProps = {
editor: Editor | null;
className?: string;
};
export function EditorToolbar({ editor, className }: EditorToolbarProps) {
if (!editor) return null;
const blockLabel = editor.isActive("heading", { level: 1 })
? "Heading 1"
: editor.isActive("heading", { level: 2 })
? "Heading 2"
: editor.isActive("heading", { level: 3 })
? "Heading 3"
: "Paragraph";
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/30 px-2 py-1.5",
className,
)}
>
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => ch(editor).toggleBold().run()}
>
<Bold className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => ch(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => ch(editor).toggleUnderline().run()}
>
<Underline className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => ch(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => ch(editor).toggleCode().run()}
>
<Code className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Highlight"
shortcut="⌘⇧H"
active={editor.isActive("highlight")}
onClick={() => ch(editor).toggleHighlight().run()}
>
<Highlighter className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1 px-2 text-xs font-normal text-muted-foreground",
editor.isActive("heading") && "text-primary",
)}
>
{blockLabel}
<ChevronDown className="size-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem
onClick={() => ch(editor).setParagraph().run()}
className="gap-2"
>
<Pilcrow className="size-4" />
Paragraph
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 1 }).run()}
className="gap-2"
>
<Heading1 className="size-4" />
Heading 1
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 2 }).run()}
className="gap-2"
>
<Heading2 className="size-4" />
Heading 2
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ch(editor).setHeading({ level: 3 }).run()}
className="gap-2"
>
<Heading3 className="size-4" />
Heading 3
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Bullet list"
shortcut="⌘⇧8"
active={editor.isActive("bulletList")}
onClick={() => ch(editor).toggleBulletList().run()}
>
<List className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Ordered list"
shortcut="⌘⇧7"
active={editor.isActive("orderedList")}
onClick={() => ch(editor).toggleOrderedList().run()}
>
<ListOrdered className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Task list"
shortcut="⌘⇧9"
active={editor.isActive("taskList")}
onClick={() => ch(editor).toggleTaskList().run()}
>
<ListTodo className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Align left"
shortcut=""
active={
editor.isActive({ textAlign: "left" }) ||
(!editor.isActive({ textAlign: "center" }) &&
!editor.isActive({ textAlign: "right" }))
}
onClick={() => ch(editor).setTextAlign("left").run()}
>
<AlignLeft className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Align center"
shortcut=""
active={editor.isActive({ textAlign: "center" })}
onClick={() => ch(editor).setTextAlign("center").run()}
>
<AlignCenter className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Align right"
shortcut=""
active={editor.isActive({ textAlign: "right" })}
onClick={() => ch(editor).setTextAlign("right").run()}
>
<AlignRight className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Insert image"
shortcut=""
active={false}
onClick={() => {
const src = window.prompt("Image URL");
if (src) ch(editor).setImage({ src }).run();
}}
>
<ImageIcon className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Horizontal rule"
shortcut=""
active={editor.isActive("horizontalRule")}
onClick={() => ch(editor).setHorizontalRule().run()}
>
<Minus className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Insert table"
shortcut=""
active={editor.isActive("table")}
onClick={() =>
ch(editor)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<Table2 className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Code block"
shortcut="⌘⌥C"
active={editor.isActive("codeBlock")}
onClick={() => ch(editor).toggleCodeBlock().run()}
>
<SquareCode className="size-4" />
</ToolbarIcon>
</div>
<Separator orientation="vertical" className="mx-1 h-7" />
<div className="flex items-center gap-0.5">
<ToolbarIcon
label="Undo"
shortcut="⌘Z"
active={false}
onClick={() => ch(editor).undo().run()}
>
<Undo2 className="size-4" />
</ToolbarIcon>
<ToolbarIcon
label="Redo"
shortcut="⌘⇧Z"
active={false}
onClick={() => ch(editor).redo().run()}
>
<Redo2 className="size-4" />
</ToolbarIcon>
</div>
</div>
</TooltipProvider>
);
}
function ToolbarIcon({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0",
active && "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary",
)}
onClick={onClick}
aria-pressed={active}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{label}</span>
{shortcut ? (
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
) : null}
</TooltipContent>
</Tooltip>
);
}