mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-09 16:54:46 +08:00
feat: add novel editor
This commit is contained in:
356
web/src/components/editor/content.ts
Normal file
356
web/src/components/editor/content.ts
Normal 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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
180
web/src/components/editor/extensions.tsx
Normal file
180
web/src/components/editor/extensions.tsx
Normal 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,
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
123
web/src/components/editor/generative/ai-selector.tsx
Normal file
123
web/src/components/editor/generative/ai-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
60
web/src/components/editor/image-upload.ts
Normal file
60
web/src/components/editor/image-upload.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
173
web/src/components/editor/index.tsx
Normal file
173
web/src/components/editor/index.tsx
Normal 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;
|
||||
192
web/src/components/editor/selectors/color-selector.tsx
Normal file
192
web/src/components/editor/selectors/color-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
web/src/components/editor/selectors/link-selector.tsx
Normal file
104
web/src/components/editor/selectors/link-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
web/src/components/editor/selectors/math-selector.tsx
Normal file
35
web/src/components/editor/selectors/math-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
web/src/components/editor/selectors/node-selector.tsx
Normal file
144
web/src/components/editor/selectors/node-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
web/src/components/editor/selectors/text-buttons.tsx
Normal file
73
web/src/components/editor/selectors/text-buttons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
219
web/src/components/editor/slash-command.tsx
Normal file
219
web/src/components/editor/slash-command.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user