feat: add novel editor

This commit is contained in:
Jiang Feng
2025-04-26 00:20:49 +08:00
parent 26c2679b1a
commit ba8c5fbcd3
22 changed files with 3760 additions and 5 deletions

View File

@@ -0,0 +1,356 @@
export const defaultEditorContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Introducing Novel" }],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://github.com/steven-tey/novel",
target: "_blank",
},
},
],
text: "Novel",
},
{
type: "text",
text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ",
},
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://tiptap.dev/",
target: "_blank",
},
},
],
text: "Tiptap",
},
{ type: "text", text: " + " },
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://sdk.vercel.ai/docs",
target: "_blank",
},
},
],
text: "Vercel AI SDK",
},
{ type: "text", text: "." },
],
},
{
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Installation" }],
},
{
type: "codeBlock",
attrs: { language: null },
content: [{ type: "text", text: "npm i novel" }],
},
{
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Usage" }],
},
{
type: "codeBlock",
attrs: { language: null },
content: [
{
type: "text",
text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n <Editor />\n )\n}',
},
],
},
{
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Features" }],
},
{
type: "orderedList",
attrs: { tight: true, start: 1 },
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Slash menu & bubble menu" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "AI autocomplete (type " },
{ type: "text", marks: [{ type: "code" }], text: "++" },
{
type: "text",
text: " to activate, or select from slash menu)",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Add tweets from the command slash menu:",
},
],
},
{
type: "twitter",
attrs: {
src: "https://x.com/elonmusk/status/1800759252224729577",
},
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Mathematical symbols with LaTeX expression:",
},
],
},
{
type: "orderedList",
attrs: {
tight: true,
start: 1,
},
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "math",
attrs: {
latex: "E = mc^2",
},
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "math",
attrs: {
latex: "a^2 = \\sqrt{b^2 + c^2}",
},
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "math",
attrs: {
latex:
"\\hat{f} (\\xi)=\\int_{-\\infty}^{\\infty}f(x)e^{-2\\pi ix\\xi}dx",
},
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "math",
attrs: {
latex:
"A=\\begin{bmatrix}a&b\\\\c&d \\end{bmatrix}",
},
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "math",
attrs: {
latex: "\\sum_{i=0}^n x_i",
},
},
],
},
],
},
],
},
],
},
],
},
{
type: "image",
attrs: {
src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png",
alt: "banner.png",
title: "banner.png",
width: null,
height: null,
},
},
{ type: "horizontalRule" },
{
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Learn more" }],
},
{
type: "taskList",
content: [
{
type: "taskItem",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Star us on " },
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://github.com/steven-tey/novel",
target: "_blank",
},
},
],
text: "GitHub",
},
],
},
],
},
{
type: "taskItem",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Install the " },
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://www.npmjs.com/package/novel",
target: "_blank",
},
},
],
text: "NPM package",
},
],
},
],
},
{
type: "taskItem",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://vercel.com/templates/next.js/novel",
target: "_blank",
},
},
],
text: "Deploy your own",
},
{ type: "text", text: " to Vercel" },
],
},
],
},
],
},
],
};

View File

@@ -0,0 +1,180 @@
import {
AIHighlight,
CharacterCount,
CodeBlockLowlight,
Color,
CustomKeymap,
GlobalDragHandle,
HighlightExtension,
HorizontalRule,
Mathematics,
Placeholder,
StarterKit,
TaskItem,
TaskList,
TextStyle,
TiptapImage,
TiptapLink,
TiptapUnderline,
Twitter,
UpdatedImage,
UploadImagesPlugin,
Youtube,
} from "novel";
import { Markdown } from "tiptap-markdown";
import { cx } from "class-variance-authority";
import { common, createLowlight } from "lowlight";
//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
const aiHighlight = AIHighlight;
//You can overwrite the placeholder with your own configuration
const placeholder = Placeholder;
const tiptapLink = TiptapLink.configure({
HTMLAttributes: {
class: cx(
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
),
},
});
const tiptapImage = TiptapImage.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin({
imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
}),
];
},
}).configure({
allowBase64: true,
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const updatedImage = UpdatedImage.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const taskList = TaskList.configure({
HTMLAttributes: {
class: cx("not-prose pl-2 "),
},
});
const taskItem = TaskItem.configure({
HTMLAttributes: {
class: cx("flex gap-2 items-start my-4"),
},
nested: true,
});
const horizontalRule = HorizontalRule.configure({
HTMLAttributes: {
class: cx("mt-4 mb-6 border-t border-muted-foreground"),
},
});
const starterKit = StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: cx("list-disc list-outside leading-3 -mt-2"),
},
},
orderedList: {
HTMLAttributes: {
class: cx("list-decimal list-outside leading-3 -mt-2"),
},
},
listItem: {
HTMLAttributes: {
class: cx("leading-normal -mb-2"),
},
},
blockquote: {
HTMLAttributes: {
class: cx("border-l-4 border-primary"),
},
},
codeBlock: false,
code: {
HTMLAttributes: {
class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"),
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
});
const codeBlockLowlight = CodeBlockLowlight.configure({
// configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only
// common: covers 37 language grammars which should be good enough in most cases
lowlight: createLowlight(common),
});
const youtube = Youtube.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
inline: false,
});
const twitter = Twitter.configure({
HTMLAttributes: {
class: cx("not-prose"),
},
inline: false,
});
const mathematics = Mathematics.configure({
HTMLAttributes: {
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
},
katexOptions: {
throwOnError: false,
},
});
const characterCount = CharacterCount.configure();
const markdownExtension = Markdown.configure({
html: true,
tightLists: true,
tightListClass: "tight",
bulletListMarker: "-",
linkify: false,
breaks: false,
transformPastedText: false,
transformCopiedText: false,
});
const globalDragHandle = GlobalDragHandle.configure({});
export const defaultExtensions = [
starterKit,
placeholder,
tiptapLink,
updatedImage,
taskList,
taskItem,
horizontalRule,
aiHighlight,
codeBlockLowlight,
youtube,
twitter,
mathematics,
characterCount,
TiptapUnderline,
markdownExtension,
HighlightExtension,
TextStyle,
Color,
CustomKeymap,
globalDragHandle,
];

View File

@@ -0,0 +1,66 @@
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
import { useEditor } from "novel";
import { Check, TextQuote, TrashIcon } from "lucide-react";
const AICompletionCommands = ({
completion,
onDiscard,
}: {
completion: string;
onDiscard: () => void;
}) => {
const { editor } = useEditor();
if (!editor) return null;
return (
<>
<CommandGroup>
<CommandItem
className="gap-2 px-4"
value="replace"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(
{
from: selection.from,
to: selection.to,
},
completion,
)
.run();
}}
>
<Check className="text-muted-foreground h-4 w-4" />
Replace selection
</CommandItem>
<CommandItem
className="gap-2 px-4"
value="insert"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(selection.to + 1, completion)
.run();
}}
>
<TextQuote className="text-muted-foreground h-4 w-4" />
Insert below
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={onDiscard} value="thrash" className="gap-2 px-4">
<TrashIcon className="text-muted-foreground h-4 w-4" />
Discard
</CommandItem>
</CommandGroup>
</>
);
};
export default AICompletionCommands;

View File

@@ -0,0 +1,81 @@
import {
ArrowDownWideNarrow,
CheckCheck,
RefreshCcwDot,
StepForward,
WrapText,
} from "lucide-react";
import { getPrevText, useEditor } from "novel";
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
const options = [
{
value: "improve",
label: "Improve writing",
icon: RefreshCcwDot,
},
{
value: "fix",
label: "Fix grammar",
icon: CheckCheck,
},
{
value: "shorter",
label: "Make shorter",
icon: ArrowDownWideNarrow,
},
{
value: "longer",
label: "Make longer",
icon: WrapText,
},
];
interface AISelectorCommandsProps {
onSelect: (value: string, option: string) => void;
}
const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
const { editor } = useEditor();
if (!editor) return null;
return (
<>
<CommandGroup heading="Edit or review selection">
{options.map((option) => (
<CommandItem
onSelect={(value) => {
const slice = editor.state.selection.content();
const text = editor.storage.markdown.serializer.serialize(
slice.content,
);
onSelect(text, value);
}}
className="flex gap-2 px-4"
key={option.value}
value={option.value}
>
<option.icon className="h-4 w-4 text-purple-500" />
{option.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Use AI to do more">
<CommandItem
onSelect={() => {
const pos = editor.state.selection.from;
const text = getPrevText(editor, pos);
onSelect(text, "continue");
}}
value="continue"
className="gap-2 px-4"
>
<StepForward className="h-4 w-4 text-purple-500" />
Continue writing
</CommandItem>
</CommandGroup>
</>
);
};
export default AISelectorCommands;

View File

@@ -0,0 +1,123 @@
"use client";
import { Command, CommandInput } from "../../ui/command";
import { useCompletion } from "@ai-sdk/react";
import { ArrowUp } from "lucide-react";
import { useEditor } from "novel";
import { addAIHighlight } from "novel";
import { useState } from "react";
import Markdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "../../ui/button";
import Magic from "../../ui/icons/magic";
import { ScrollArea } from "../../ui/scroll-area";
import AICompletionCommands from "./ai-completion-command";
import AISelectorCommands from "./ai-selector-commands";
import { LoadingOutlined } from "@ant-design/icons";
//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction
interface AISelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AISelector({ onOpenChange }: AISelectorProps) {
const { editor } = useEditor();
const [inputValue, setInputValue] = useState("");
const { completion, complete, isLoading } = useCompletion({
// id: "novel",
api: "/api/generate",
onResponse: (response) => {
if (response.status === 429) {
toast.error("You have reached your request limit for the day.");
return;
}
},
onError: (e) => {
toast.error(e.message);
},
});
if (!editor) return null;
const hasCompletion = completion.length > 0;
return (
<Command className="w-[350px]">
{hasCompletion && (
<div className="flex max-h-[400px]">
<ScrollArea>
<div className="prose prose-sm p-2 px-4">
<Markdown>{completion}</Markdown>
</div>
</ScrollArea>
</div>
)}
{isLoading && (
<div className="text-muted-foreground flex h-12 w-full items-center px-4 text-sm font-medium text-purple-500">
<Magic className="mr-2 h-4 w-4 shrink-0" />
AI is thinking
<div className="mt-1 ml-2">
<LoadingOutlined />
</div>
</div>
)}
{!isLoading && (
<>
<div className="relative">
<CommandInput
value={inputValue}
onValueChange={setInputValue}
autoFocus
placeholder={
hasCompletion
? "Tell AI what to do next"
: "Ask AI to edit or generate..."
}
onFocus={() => addAIHighlight(editor)}
/>
<Button
size="icon"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 rounded-full bg-purple-500 hover:bg-purple-900"
onClick={() => {
if (completion)
return complete(completion, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
const slice = editor.state.selection.content();
const text = editor.storage.markdown.serializer.serialize(
slice.content,
);
complete(text, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
}}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
{hasCompletion ? (
<AICompletionCommands
onDiscard={() => {
editor.chain().unsetHighlight().focus().run();
onOpenChange(false);
}}
completion={completion}
/>
) : (
<AISelectorCommands
onSelect={(value, option) =>
complete(value, { body: { option } })
}
/>
)}
</>
)}
</Command>
);
}

View File

@@ -0,0 +1,54 @@
import { EditorBubble, removeAIHighlight, useEditor } from "novel";
import { Fragment, type ReactNode, useEffect } from "react";
import { Button } from "../../ui/button";
import Magic from "../../ui/icons/magic";
import { AISelector } from "./ai-selector";
interface GenerativeMenuSwitchProps {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const GenerativeMenuSwitch = ({
children,
open,
onOpenChange,
}: GenerativeMenuSwitchProps) => {
const { editor } = useEditor();
useEffect(() => {
if (!open && editor) removeAIHighlight(editor);
}, [open]);
if (!editor) return null;
return (
<EditorBubble
tippyOptions={{
placement: open ? "bottom-start" : "top",
onHidden: () => {
onOpenChange(false);
editor.chain().unsetHighlight().run();
},
}}
className="border-muted bg-background flex w-fit max-w-[90vw] overflow-hidden rounded-md border shadow-xl"
>
{open && <AISelector open={open} onOpenChange={onOpenChange} />}
{!open && (
<Fragment>
<Button
className="gap-1 rounded-none text-purple-500"
variant="ghost"
onClick={() => onOpenChange(true)}
size="sm"
>
<Magic className="h-5 w-5" />
Ask AI
</Button>
{children}
</Fragment>
)}
</EditorBubble>
);
};
export default GenerativeMenuSwitch;

View File

@@ -0,0 +1,60 @@
import { createImageUpload } from "novel";
import { toast } from "sonner";
const onUpload = (file: File) => {
const promise = fetch("/api/upload", {
method: "POST",
headers: {
"content-type": file?.type || "application/octet-stream",
"x-vercel-filename": file?.name || "image.png",
},
body: file,
});
return new Promise((resolve, reject) => {
toast.promise(
promise.then(async (res) => {
// Successfully uploaded image
if (res.status === 200) {
const { url } = (await res.json()) as { url: string };
// preload the image
const image = new Image();
image.src = url;
image.onload = () => {
resolve(url);
};
// No blob store configured
} else if (res.status === 401) {
resolve(file);
throw new Error("`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead.");
// Unknown error
} else {
throw new Error("Error uploading image. Please try again.");
}
}),
{
loading: "Uploading image...",
success: "Image uploaded successfully.",
error: (e) => {
reject(e);
return e.message;
},
},
);
});
};
export const uploadFn = createImageUpload({
onUpload,
validateFn: (file) => {
if (!file.type.includes("image/")) {
toast.error("File type not supported.");
return false;
}
if (file.size / 1024 / 1024 > 20) {
toast.error("File size too big (max 20MB).");
return false;
}
return true;
},
});

View File

@@ -0,0 +1,173 @@
"use client";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
EditorContent,
type EditorInstance,
EditorRoot,
ImageResizer,
type JSONContent,
handleCommandNavigation,
handleImageDrop,
handleImagePaste,
} from "novel";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { defaultExtensions } from "./extensions";
import { ColorSelector } from "./selectors/color-selector";
import { LinkSelector } from "./selectors/link-selector";
import { MathSelector } from "./selectors/math-selector";
import { NodeSelector } from "./selectors/node-selector";
import { Separator } from "../ui/separator";
import GenerativeMenuSwitch from "./generative/generative-menu-switch";
import { uploadFn } from "./image-upload";
import { TextButtons } from "./selectors/text-buttons";
import { slashCommand, suggestionItems } from "./slash-command";
import { defaultEditorContent } from "./content";
import "~/styles/prosemirror.css";
const hljs = require("highlight.js");
const extensions = [...defaultExtensions, slashCommand];
const ReportEditor = () => {
const [initialContent, setInitialContent] = useState<null | JSONContent>(
null,
);
const [saveStatus, setSaveStatus] = useState("Saved");
const [charsCount, setCharsCount] = useState();
const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [openAI, setOpenAI] = useState(false);
//Apply Codeblock Highlighting on the HTML from editor.getHTML()
const highlightCodeblocks = (content: string) => {
const doc = new DOMParser().parseFromString(content, "text/html");
doc.querySelectorAll("pre code").forEach((el) => {
// @ts-ignore
// https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement
hljs.highlightElement(el);
});
return new XMLSerializer().serializeToString(doc);
};
const debouncedUpdates = useDebouncedCallback(
async (editor: EditorInstance) => {
const json = editor.getJSON();
setCharsCount(editor.storage.characterCount.words());
window.localStorage.setItem(
"html-content",
highlightCodeblocks(editor.getHTML()),
);
window.localStorage.setItem("novel-content", JSON.stringify(json));
window.localStorage.setItem(
"markdown",
editor.storage.markdown.getMarkdown(),
);
setSaveStatus("Saved");
},
500,
);
useEffect(() => {
const content = window.localStorage.getItem("novel-content");
if (content) setInitialContent(JSON.parse(content));
else setInitialContent(defaultEditorContent);
}, []);
if (!initialContent) return null;
return (
<div className="relative w-full">
<div className="absolute top-5 right-5 z-10 mb-5 flex gap-2">
<div className="bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm">
{saveStatus}
</div>
<div
className={
charsCount
? "bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm"
: "hidden"
}
>
{charsCount} Words
</div>
</div>
<EditorRoot>
<EditorContent
immediatelyRender={false}
initialContent={initialContent}
extensions={extensions}
className="border-muted bg-background relative h-full w-full overflow-auto sm:mb-[calc(20vh)] sm:border sm:shadow-lg"
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 prose-base prose-p:my-4 dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
},
}}
onUpdate={({ editor }) => {
debouncedUpdates(editor);
setSaveStatus("Unsaved");
}}
slotAfter={<ImageResizer />}
>
<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">
<EditorCommandEmpty className="text-muted-foreground px-2">
No results
</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command?.(val)}
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-sm"
key={item.title}
>
<div className="border-muted bg-background flex h-10 w-10 items-center justify-center rounded-md border">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground text-xs">
{item.description}
</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
<GenerativeMenuSwitch open={openAI} onOpenChange={setOpenAI}>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<MathSelector />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</GenerativeMenuSwitch>
</EditorContent>
</EditorRoot>
</div>
);
};
export default ReportEditor;

View File

@@ -0,0 +1,192 @@
import { Check, ChevronDown } from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";
import { Button } from "../../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
export interface BubbleColorMenuItem {
name: string;
color: string;
}
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-black)",
},
{
name: "Purple",
color: "#9333EA",
},
{
name: "Red",
color: "#E00000",
},
{
name: "Yellow",
color: "#EAB308",
},
{
name: "Blue",
color: "#2563EB",
},
{
name: "Green",
color: "#008A00",
},
{
name: "Orange",
color: "#FFA500",
},
{
name: "Pink",
color: "#BA4081",
},
{
name: "Gray",
color: "#A8A29E",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-highlight-default)",
},
{
name: "Purple",
color: "var(--novel-highlight-purple)",
},
{
name: "Red",
color: "var(--novel-highlight-red)",
},
{
name: "Yellow",
color: "var(--novel-highlight-yellow)",
},
{
name: "Blue",
color: "var(--novel-highlight-blue)",
},
{
name: "Green",
color: "var(--novel-highlight-green)",
},
{
name: "Orange",
color: "var(--novel-highlight-orange)",
},
{
name: "Pink",
color: "var(--novel-highlight-pink)",
},
{
name: "Gray",
color: "var(--novel-highlight-gray)",
},
];
interface ColorSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
const { editor } = useEditor();
if (!editor) return null;
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }),
);
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
<span
className="rounded-sm px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}
>
A
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
sideOffset={5}
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl"
align="start"
>
<div className="flex flex-col">
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">
Color
</div>
{TEXT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
onOpenChange(false);
}}
className="hover:bg-accent flex cursor-pointer items-center justify-between px-2 py-1 text-sm"
>
<div className="flex items-center gap-2">
<div
className="rounded-sm border px-2 py-px font-medium"
style={{ color }}
>
A
</div>
<span>{name}</span>
</div>
</EditorBubbleItem>
))}
</div>
<div>
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">
Background
</div>
{HIGHLIGHT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
editor.commands.unsetHighlight();
name !== "Default" &&
editor.chain().focus().setHighlight({ color }).run();
onOpenChange(false);
}}
className="hover:bg-accent flex cursor-pointer items-center justify-between px-2 py-1 text-sm"
>
<div className="flex items-center gap-2">
<div
className="rounded-sm border px-2 py-px font-medium"
style={{ backgroundColor: color }}
>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && (
<Check className="h-4 w-4" />
)}
</EditorBubbleItem>
))}
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,104 @@
import { Button } from "../../ui/button";
import { PopoverContent } from "../../ui/popover";
import { cn } from "../../../lib/utils";
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
import { Check, Trash } from "lucide-react";
import { useEditor } from "novel";
import { useEffect, useRef } from "react";
export function isValidUrl(url: string) {
try {
new URL(url);
return true;
} catch (_e) {
return false;
}
}
export function getUrlFromString(str: string) {
if (isValidUrl(str)) return str;
try {
if (str.includes(".") && !str.includes(" ")) {
return new URL(`https://${str}`).toString();
}
} catch (_e) {
return null;
}
}
interface LinkSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const { editor } = useEditor();
// Autofocus on input by default
useEffect(() => {
inputRef.current?.focus();
});
if (!editor) return null;
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="gap-2 rounded-none border-none"
>
<p className="text-base"></p>
<p
className={cn("underline decoration-stone-400 underline-offset-4", {
"text-blue-500": editor.isActive("link"),
})}
>
Link
</p>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
<form
onSubmit={(e) => {
const target = e.currentTarget as HTMLFormElement;
e.preventDefault();
const input = target[0] as HTMLInputElement;
const url = getUrlFromString(input.value);
if (url) {
editor.chain().focus().setLink({ href: url }).run();
onOpenChange(false);
}
}}
className="flex p-1"
>
<input
ref={inputRef}
type="text"
placeholder="Paste a link"
className="bg-background flex-1 p-1 text-sm outline-none"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<Button
size="icon"
variant="outline"
type="button"
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
if (inputRef.current) inputRef.current.value = "";
onOpenChange(false);
}}
>
<Trash className="h-4 w-4" />
</Button>
) : (
<Button size="icon" className="h-8">
<Check className="h-4 w-4" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,35 @@
import { Button } from "../../ui/button";
import { cn } from "../../../lib/utils";
import { SigmaIcon } from "lucide-react";
import { useEditor } from "novel";
export const MathSelector = () => {
const { editor } = useEditor();
if (!editor) return null;
return (
<Button
variant="ghost"
size="sm"
className="w-12 rounded-none"
onClick={(evt) => {
if (editor.isActive("math")) {
editor.chain().focus().unsetLatex().run();
} else {
const { from, to } = editor.state.selection;
const latex = editor.state.doc.textBetween(from, to);
if (!latex) return;
editor.chain().focus().setLatex({ latex }).run();
}
}}
>
<SigmaIcon
className={cn("size-4", { "text-blue-500": editor.isActive("math") })}
strokeWidth={2.3}
/>
</Button>
);
};

View File

@@ -0,0 +1,144 @@
import {
Check,
CheckSquare,
ChevronDown,
Code,
Heading1,
Heading2,
Heading3,
ListOrdered,
type LucideIcon,
TextIcon,
TextQuote,
} from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";
import { Button } from "../../ui/button";
import { PopoverContent, PopoverTrigger } from "../../ui/popover";
import { Popover } from "@radix-ui/react-popover";
export type SelectorItem = {
name: string;
icon: LucideIcon;
command: (
editor: NonNullable<ReturnType<typeof useEditor>["editor"]>,
) => void;
isActive: (
editor: NonNullable<ReturnType<typeof useEditor>["editor"]>,
) => boolean;
};
const items: SelectorItem[] = [
{
name: "Text",
icon: TextIcon,
command: (editor) => editor.chain().focus().clearNodes().run(),
// I feel like there has to be a more efficient way to do this feel free to PR if you know how!
isActive: (editor) =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "Heading 1",
icon: Heading1,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: (editor) =>
editor.chain().focus().clearNodes().toggleTaskList().run(),
isActive: (editor) => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: (editor) =>
editor.chain().focus().clearNodes().toggleBulletList().run(),
isActive: (editor) => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: (editor) =>
editor.chain().focus().clearNodes().toggleOrderedList().run(),
isActive: (editor) => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: (editor) =>
editor.chain().focus().clearNodes().toggleBlockquote().run(),
isActive: (editor) => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: (editor) =>
editor.chain().focus().clearNodes().toggleCodeBlock().run(),
isActive: (editor) => editor.isActive("codeBlock"),
},
];
interface NodeSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
const { editor } = useEditor();
if (!editor) return null;
const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
name: "Multiple",
};
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
asChild
className="hover:bg-accent gap-2 rounded-none border-none focus:ring-0"
>
<Button size="sm" variant="ghost" className="gap-2">
<span className="text-sm whitespace-nowrap">{activeItem.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
onOpenChange(false);
}}
className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm"
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</EditorBubbleItem>
))}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,73 @@
import { Button } from "../../ui/button";
import { cn } from "../../../lib/utils";
import {
BoldIcon,
CodeIcon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
} from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";
import type { SelectorItem } from "./node-selector";
export const TextButtons = () => {
const { editor } = useEditor();
if (!editor) return null;
const items: SelectorItem[] = [
{
name: "bold",
isActive: (editor) => editor.isActive("bold"),
command: (editor) => editor.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: (editor) => editor.isActive("italic"),
command: (editor) => editor.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: (editor) => editor.isActive("underline"),
command: (editor) => editor.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: (editor) => editor.isActive("strike"),
command: (editor) => editor.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: (editor) => editor.isActive("code"),
command: (editor) => editor.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
return (
<div className="flex">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
}}
>
<Button
size="sm"
className="rounded-none"
variant="ghost"
type="button"
>
<item.icon
className={cn("h-4 w-4", {
"text-blue-500": item.isActive(editor),
})}
/>
</Button>
</EditorBubbleItem>
))}
</div>
);
};

View File

@@ -0,0 +1,219 @@
import {
Brain,
CheckSquare,
Code,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
Twitter,
Youtube,
} from "lucide-react";
import { Command, createSuggestionItems, renderItems } from "novel";
import { uploadFn } from "./image-upload";
export const suggestionItems = createSuggestionItems([
{
title: "Send Feedback",
description: "Let us know how we can improve.",
icon: <MessageSquarePlus size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
window.open("/feedback", "_blank");
},
},
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
if (!file) return;
const pos = editor.view.state.selection.from;
uploadFn(file, editor.view, pos);
}
};
input.click();
},
},
{
title: "Youtube",
description: "Embed a Youtube video.",
searchTerms: ["video", "youtube", "embed"],
icon: <Youtube size={18} />,
command: ({ editor, range }) => {
const videoLink = prompt("Please enter Youtube Video Link");
//From https://regexr.com/3dj5t
const ytRegex = new RegExp(
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
);
if (videoLink && ytRegex.test(videoLink)) {
editor
.chain()
.focus()
.deleteRange(range)
.setYoutubeVideo({
src: videoLink,
})
.run();
} else {
if (videoLink !== null) {
alert("Please enter a correct Youtube Video Link");
}
}
},
},
{
title: "Twitter",
description: "Embed a Tweet.",
searchTerms: ["twitter", "embed"],
icon: <Twitter size={18} />,
command: ({ editor, range }) => {
const tweetLink = prompt("Please enter Twitter Link");
const tweetRegex = new RegExp(
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/,
);
if (tweetLink && tweetRegex.test(tweetLink)) {
editor
.chain()
.focus()
.deleteRange(range)
.setTweet({
src: tweetLink,
})
.run();
} else {
if (tweetLink !== null) {
alert("Please enter a correct Twitter Link");
}
}
},
},
]);
export const slashCommand = Command.configure({
suggestion: {
items: () => suggestionItems,
render: renderItems,
},
});