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>
);
};

View File

@@ -1,73 +1,65 @@
"use client";
import * as React from "react";
import {
MDXEditor,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
toolbarPlugin,
linkPlugin,
linkDialogPlugin,
type MDXEditorMethods,
KitchenSinkToolbar,
tablePlugin,
imagePlugin,
frontmatterPlugin,
codeBlockPlugin,
directivesPlugin,
AdmonitionDirectiveDescriptor,
} from "@mdxeditor/editor";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TTImage from "@tiptap/extension-image";
import TextAlign from "@tiptap/extension-text-align";
import Youtube from "@tiptap/extension-youtube";
import Dropcursor from "@tiptap/extension-dropcursor";
import { EditorMenu } from "./editor-menu";
interface EditorProps {
markdown: string;
content: string;
onChange?: (markdown: string) => void;
className?: string;
}
export function Editor({ markdown, onChange, className }: EditorProps) {
const ref = React.useRef<MDXEditorMethods>(null);
export function Editor({ content, onChange, className }: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link,
Youtube,
Dropcursor,
TTImage.configure({
HTMLAttributes: {
class: "max-w-full w-auto h-auto resize overflow-auto",
},
}),
TextAlign.configure({
alignments: ["left", "center", "right", "justify"],
defaultAlignment: "justify",
types: ["heading", "paragraph"],
}),
],
content,
immediatelyRender: false,
editorProps: {
attributes: {
class: "prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none dark:prose-invert",
},
},
onUpdate: ({ editor }) => {
console.log("Update");
localStorage.setItem("blog_draft", editor.getHTML());
},
});
const onChangeDebounce = (content: string) => {
localStorage.setItem("blog_draft", content);
};
if (!editor) {
return null;
}
return (
<Card className={cn("border rounded-lg overflow-hidden", className)}>
<MDXEditor
ref={ref}
className="mdx-editor dark-theme dark-editor prose dark:prose-invert p-4"
markdown={markdown}
onChange={onChangeDebounce}
spellCheck
plugins={[
toolbarPlugin({
toolbarContents: () => <KitchenSinkToolbar />,
}),
listsPlugin(),
quotePlugin(),
headingsPlugin(),
linkPlugin(),
linkDialogPlugin(),
// eslint-disable-next-line @typescript-eslint/require-await
imagePlugin({
imageUploadHandler: async () => "/sample-image.png",
}),
tablePlugin(),
thematicBreakPlugin(),
frontmatterPlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: "txt" }),
directivesPlugin({
directiveDescriptors: [AdmonitionDirectiveDescriptor],
}),
markdownShortcutPlugin(),
]}
/>
<EditorMenu editor={editor} />
<EditorContent editor={editor} />
</Card>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };