Files
Zero/apps/mail/components/create/editor.tsx

376 lines
13 KiB
TypeScript

"use client";
import {
Bold,
Italic,
Strikethrough,
Underline,
Code,
Link as LinkIcon,
List,
ListOrdered,
Heading1,
Heading2,
Heading3,
} from "lucide-react";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
EditorContent,
EditorRoot,
type JSONContent,
} from "novel";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TextButtons } from "@/components/create/selectors/text-buttons";
import { suggestionItems } from "@/components/create/slash-command";
import { defaultExtensions } from "@/components/create/extensions";
import { AnyExtension, useCurrentEditor } from "@tiptap/react";
import { ImageResizer, handleCommandNavigation } from "novel";
import { uploadFn } from "@/components/create/image-upload";
import { handleImageDrop, handleImagePaste } from "novel";
import EditorMenu from "@/components/create/editor-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Markdown } from "tiptap-markdown";
import { useReducer, useRef } from "react";
import { useState } from "react";
const extensions: AnyExtension[] = [...defaultExtensions, Markdown];
export const defaultEditorContent = {
type: "doc",
content: [
{
type: "paragraph",
content: [],
},
],
};
interface EditorProps {
initialValue?: JSONContent;
onChange: (content: string) => void;
placeholder?: string;
}
interface EditorState {
openNode: boolean;
openColor: boolean;
openLink: boolean;
openAI: boolean;
}
type EditorAction =
| { type: "TOGGLE_NODE"; payload: boolean }
| { type: "TOGGLE_COLOR"; payload: boolean }
| { type: "TOGGLE_LINK"; payload: boolean }
| { type: "TOGGLE_AI"; payload: boolean };
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case "TOGGLE_NODE":
return { ...state, openNode: action.payload };
case "TOGGLE_COLOR":
return { ...state, openColor: action.payload };
case "TOGGLE_LINK":
return { ...state, openLink: action.payload };
case "TOGGLE_AI":
return { ...state, openAI: action.payload };
default:
return state;
}
}
// Update the MenuBar component with icons
const MenuBar = () => {
const { editor } = useCurrentEditor();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
if (!editor) {
return null;
}
// Replace the old setLink function with this new implementation
const handleLinkDialogOpen = () => {
// If a link is already active, pre-fill the input with the current URL
if (editor.isActive("link")) {
const attrs = editor.getAttributes("link");
setLinkUrl(attrs.href || "");
} else {
setLinkUrl("");
}
setLinkDialogOpen(true);
};
const handleSaveLink = () => {
// empty
if (linkUrl === "") {
editor.chain().focus().unsetLink().run();
} else {
// Format the URL with proper protocol if missing
let formattedUrl = linkUrl;
if (formattedUrl && !/^https?:\/\//i.test(formattedUrl)) {
formattedUrl = `https://${formattedUrl}`;
}
// set link
editor.chain().focus().setLink({ href: formattedUrl }).run();
}
setLinkDialogOpen(false);
};
const handleRemoveLink = () => {
editor.chain().focus().unsetLink().run();
setLinkDialogOpen(false);
};
return (
<>
<div className="control-group mb-2 overflow-x-auto">
<div className="button-group ml-2 mt-1 flex flex-wrap gap-1 border-b pb-2">
<div className="mr-2 flex items-center gap-1">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("heading", { level: 1 }) ? "bg-muted" : "bg-background"}`}
title="Heading 1"
>
<Heading1 className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("heading", { level: 2 }) ? "bg-muted" : "bg-background"}`}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("heading", { level: 3 }) ? "bg-muted" : "bg-background"}`}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</button>
</div>
<div className="mr-2 flex items-center gap-1">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("bold") ? "bg-muted" : "bg-background"}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("italic") ? "bg-muted" : "bg-background"}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("strike") ? "bg-muted" : "bg-background"}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("underline") ? "bg-muted" : "bg-background"}`}
title="Underline"
>
<Underline className="h-4 w-4" />
</button>
<button
onClick={handleLinkDialogOpen}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("link") ? "bg-muted" : "bg-background"}`}
title="Link"
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("bulletList") ? "bg-muted" : "bg-background"}`}
title="Bullet List"
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`hover:bg-muted rounded p-1.5 ${editor.isActive("orderedList") ? "bg-muted" : "bg-background"}`}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</button>
</div>
</div>
</div>
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
<DialogDescription>
Add a URL to create a link. The link will open in a new tab.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label htmlFor="url" className="text-sm font-medium">
URL
</label>
<Input
id="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
/>
</div>
</div>
<DialogFooter className="flex justify-between sm:justify-between">
<Button variant="outline" onClick={handleRemoveLink} type="button">
Cancel
</Button>
<Button onClick={handleSaveLink} type="button">
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default function Editor({
initialValue,
onChange,
placeholder = "Write something...",
}: EditorProps) {
const [state, dispatch] = useReducer(editorReducer, {
openNode: false,
openColor: false,
openLink: false,
openAI: false,
});
// Add a ref to store the editor content to prevent losing it on refresh
const contentRef = useRef<string>("");
// Add a ref to the editor instance
const editorRef = useRef<any>(null);
const { openNode, openColor, openLink, openAI } = state;
// Function to focus the editor
const focusEditor = () => {
if (editorRef.current) {
editorRef.current.commands.focus();
}
};
return (
<div
className="relative w-full max-w-[450px] sm:max-w-[600px]"
onClick={focusEditor} // Add click handler to focus the editor
onKeyDown={(e) => {
// Prevent form submission on Enter key
if (e.key === "Enter" && !e.shiftKey) {
e.stopPropagation();
}
}}
>
<EditorRoot>
<EditorContent
immediatelyRender={false}
initialContent={initialValue || defaultEditorContent}
extensions={extensions}
className="min-h-96 max-w-[450px] sm:max-w-[600px]"
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
handlePaste: (view, event) => handleImagePaste(view, event, uploadFn),
handleDrop: (view, event, _slice, moved) =>
handleImageDrop(view, event, moved, uploadFn),
attributes: {
class:
"prose dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
"data-placeholder": placeholder,
},
}}
onUpdate={({ editor }) => {
// Store the content in the ref to prevent losing it
contentRef.current = editor.getHTML();
// Store the editor instance in the ref
editorRef.current = editor;
onChange(editor.getHTML());
}}
slotBefore={<MenuBar />}
slotAfter={<ImageResizer />}
>
{/* Make sure the command palette doesn't cause a refresh */}
<EditorCommand
className="border-muted bg-background z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border px-1 py-2 shadow-md transition-all"
onKeyDown={(e) => {
// Prevent form submission on any key that might trigger it
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
e.stopPropagation();
}
}}
>
{/* Rest of the command palette */}
<EditorCommandEmpty className="text-muted-foreground px-2">
No results
</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => {
// Prevent default behavior that might cause refresh
item.command?.(val);
return false;
}}
className="hover:bg-accent aria-selected:bg-accent flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-[10px]"
key={item.title}
>
<div className="border-muted bg-background flex h-8 w-8 items-center justify-center rounded-md border">
{item.icon}
</div>
<div>
<p className="text-xs font-medium">{item.title}</p>
<p className="text-muted-foreground text-[8px]">{item.description}</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
{/* Replace the default editor menu with just our TextButtons */}
<EditorMenu
open={openAI}
onOpenChange={(open) => dispatch({ type: "TOGGLE_AI", payload: open })}
>
<TextButtons />
</EditorMenu>
</EditorContent>
</EditorRoot>
</div>
);
}