This commit is contained in:
cdricms
2025-02-20 19:45:11 +01:00
parent 07c632dafe
commit 5bdfc9ce06
9 changed files with 1554 additions and 3092 deletions

View File

@@ -0,0 +1,630 @@
"use client";
import type { Editor } from "@tiptap/react";
import {
AlignCenter,
AlignJustifyIcon,
AlignLeft,
AlignRight,
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
LinkIcon,
List,
ListOrdered,
Minus,
Pilcrow,
Quote,
Strikethrough,
Underline,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Level } from "@tiptap/extension-heading";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
interface EditorMenuProps {
editor: Editor | null;
}
export function EditorMenu({ editor }: EditorMenuProps) {
if (!editor) {
return null;
}
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-wrap gap-2 rounded-t-lg border bg-background p-1">
<div className="flex flex-wrap gap-2">
<ToggleGroup
type="multiple"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
aria-selected={editor.isActive("bold")}
onClick={() =>
editor
.chain()
.focus()
.toggleBold()
.run()
}
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
aria-selected={editor.isActive("italic")}
onClick={() =>
editor
.chain()
.focus()
.toggleItalic()
.run()
}
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Italic</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
aria-selected={editor.isActive("underline")}
onClick={() =>
editor
.chain()
.focus()
.toggleUnderline()
.run()
}
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Underline</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="strike"
aria-label="Toggle strikethrough"
aria-selected={editor.isActive("strike")}
onClick={() =>
editor
.chain()
.focus()
.toggleStrike()
.run()
}
>
<Strikethrough className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Strikethrough</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="code"
aria-label="Toggle code"
aria-selected={editor.isActive("code")}
onClick={() =>
editor
.chain()
.focus()
.toggleCode()
.run()
}
>
<Code className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Code</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
<Select
value={getActiveHeading(editor)}
onValueChange={(value) => {
if (value === "paragraph") {
editor.chain().focus().setParagraph().run();
} else {
editor
.chain()
.focus()
.toggleHeading({
level: Number.parseInt(value) as Level,
})
.run();
}
}}
>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue placeholder="Paragraph" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="paragraph">
<div className="flex items-center gap-2">
<Pilcrow className="h-4 w-4" />
<span>Paragraph</span>
</div>
</SelectItem>
<SelectItem value="1">
<div className="flex items-center gap-2">
<Heading1 className="h-4 w-4" />
<span>Heading 1</span>
</div>
</SelectItem>
<SelectItem value="2">
<div className="flex items-center gap-2">
<Heading2 className="h-4 w-4" />
<span>Heading 2</span>
</div>
</SelectItem>
<SelectItem value="3">
<div className="flex items-center gap-2">
<Heading3 className="h-4 w-4" />
<span>Heading 3</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Separator orientation="vertical" className="h-8" />
<ToggleGroup
type="single"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="left"
aria-label="Align left"
aria-selected={editor.isActive({
textAlign: "left",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("left")
.run()
}
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align left</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="center"
aria-label="Align center"
aria-selected={editor.isActive({
textAlign: "center",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("center")
.run()
}
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align center</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="right"
aria-label="Align right"
aria-selected={editor.isActive({
textAlign: "right",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("right")
.run()
}
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align right</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="justify"
aria-label="Justify"
aria-selected={editor.isActive({
textAlign: "justify",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("justify")
.run()
}
>
<AlignJustifyIcon className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align justify</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
<ToggleGroup
type="multiple"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="bulletList"
aria-label="Toggle bullet list"
aria-selected={editor.isActive(
"bulletList",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleBulletList()
.run()
}
>
<List className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Bullet list</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="orderedList"
aria-label="Toggle ordered list"
aria-selected={editor.isActive(
"orderedList",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleOrderedList()
.run()
}
>
<ListOrdered className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Ordered list</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="codeBlock"
aria-label="Toggle code block"
pressed={editor.isActive("codeBlock")}
onPressedChanged={() =>
editor
.chain()
.focus()
.toggleCodeBlock()
.run()
}
>
<Code2 className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Code block</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="blockquote"
aria-label="Toggle blockquote"
aria-selected={editor.isActive(
"blockquote",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleBlockquote()
.run()
}
>
<Quote className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Blockquote</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.setHorizontalRule()
.run()
}
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Horizontal rule</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.clearNodes()
.unsetAllMarks()
.run()
}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear formatting</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-8" />
<Tooltip>
<TooltipTrigger asChild>
<LinkPopover editor={editor} />
</TooltipTrigger>
<TooltipContent>Add link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ImageDialog editor={editor} />
</TooltipTrigger>
<TooltipContent>Add image</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</TooltipProvider>
);
}
function getActiveHeading(editor: Editor) {
if (editor.isActive("heading", { level: 1 })) return "1";
if (editor.isActive("heading", { level: 2 })) return "2";
if (editor.isActive("heading", { level: 3 })) return "3";
return "paragraph";
}
const LinkPopover: React.FC<{ editor: Editor }> = ({ editor }) => {
const [linkUrl, setLinkUrl] = useState("");
const [linkNewTab, setLinkNewTab] = useState(false);
const [isLinkOpen, setIsLinkOpen] = useState(false);
const setLink = () => {
if (linkUrl) {
editor
.chain()
.focus()
.setLink({
href: linkUrl,
target: linkNewTab ? "_blank" : null,
})
.run();
} else {
editor.chain().focus().unsetLink().run();
}
setIsLinkOpen(false);
setLinkUrl("");
setLinkNewTab(false);
};
return (
<Popover open={isLinkOpen} onOpenChange={setIsLinkOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={editor.isActive("link") ? "bg-accent" : ""}
>
<LinkIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="link">Link URL</Label>
<Input
id="link"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="new-tab"
checked={linkNewTab}
onCheckedChange={setLinkNewTab}
/>
<Label htmlFor="new-tab">Open in new tab</Label>
</div>
<div className="flex gap-2">
<Button onClick={setLink} className="flex-1">
Save
</Button>
{editor.isActive("link") && (
<Button
variant="outline"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsLinkOpen(false);
}}
>
Remove
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const ImageDialog: React.FC<{ editor: Editor }> = ({ editor }) => {
const [isImageOpen, setIsImageOpen] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [imageAlt, setImageAlt] = useState("");
const addImage = () => {
if (imageUrl) {
editor
.chain()
.focus()
.setImage({
src: imageUrl,
alt: imageAlt,
})
.run();
}
setIsImageOpen(false);
setImageUrl("");
setImageAlt("");
};
return (
<Dialog open={isImageOpen} onOpenChange={setIsImageOpen}>
<Button
variant="outline"
size="sm"
onClick={() => setIsImageOpen(true)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Add image</DialogTitle>
<DialogDescription>
Insert an image by providing its URL and alt text.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="image-url">Image URL</Label>
<Input
id="image-url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="image-alt">Alt text</Label>
<Input
id="image-alt"
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
placeholder="Description of the image"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsImageOpen(false)}
>
Cancel
</Button>
<Button onClick={addImage}>Add image</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};