deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import {
|
||||
AIHighlight,
|
||||
CharacterCount,
|
||||
CodeBlockLowlight,
|
||||
Color,
|
||||
CustomKeymap,
|
||||
GlobalDragHandle,
|
||||
HighlightExtension,
|
||||
HorizontalRule,
|
||||
Placeholder,
|
||||
StarterKit,
|
||||
TaskItem,
|
||||
TaskList,
|
||||
TextStyle,
|
||||
TiptapImage,
|
||||
TiptapLink,
|
||||
TiptapUnderline,
|
||||
Twitter,
|
||||
UpdatedImage,
|
||||
UploadImagesPlugin,
|
||||
Youtube,
|
||||
} from "novel";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { cx } from "class-variance-authority";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { MathematicsWithMarkdown } from "./math-serializer";
|
||||
|
||||
//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: {},
|
||||
});
|
||||
|
||||
const starterKit = StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: cx("list-decimal list-outside leading-3 -mt-2"),
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: cx("border-l-4 border-primary"),
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
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 = MathematicsWithMarkdown.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
|
||||
},
|
||||
katexOptions: {
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const characterCount = CharacterCount.configure();
|
||||
|
||||
const table = Table.configure();
|
||||
const tableRow = TableRow.configure();
|
||||
const tableCell = TableCell.configure();
|
||||
const tableHeader = TableHeader.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,
|
||||
table,
|
||||
tableRow,
|
||||
tableCell,
|
||||
tableHeader,
|
||||
horizontalRule,
|
||||
aiHighlight,
|
||||
codeBlockLowlight,
|
||||
youtube,
|
||||
twitter,
|
||||
mathematics,
|
||||
characterCount,
|
||||
TiptapUnderline,
|
||||
markdownExtension,
|
||||
HighlightExtension,
|
||||
TextStyle,
|
||||
Color,
|
||||
CustomKeymap,
|
||||
globalDragHandle,
|
||||
];
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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,85 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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,
|
||||
},
|
||||
// TODO: add this back in
|
||||
// {
|
||||
// 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;
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
"use client";
|
||||
|
||||
import { Command, CommandInput } from "../../ui/command";
|
||||
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useEditor } from "novel";
|
||||
import { addAIHighlight } from "novel";
|
||||
import { useCallback, 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";
|
||||
import { resolveServiceURL } from "~/core/api/resolve-service-url";
|
||||
import { fetchStream } from "~/core/sse";
|
||||
//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;
|
||||
}
|
||||
|
||||
function useProseCompletion() {
|
||||
const [completion, setCompletion] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const complete = useCallback(
|
||||
async (prompt: string, options?: { body?: Record<string, any> }) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchStream(
|
||||
resolveServiceURL("/api/prose/generate"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
...options?.body,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let fullText = "";
|
||||
|
||||
// Process the streaming response with debounced updates
|
||||
let chunkBuffer = "";
|
||||
let updateTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (updateTimer) clearTimeout(updateTimer);
|
||||
updateTimer = setTimeout(() => {
|
||||
if (chunkBuffer) {
|
||||
fullText += chunkBuffer;
|
||||
setCompletion(fullText);
|
||||
chunkBuffer = "";
|
||||
}
|
||||
}, 16); // ~60fps
|
||||
};
|
||||
|
||||
for await (const chunk of response) {
|
||||
chunkBuffer += chunk.data;
|
||||
scheduleUpdate();
|
||||
}
|
||||
// Final update
|
||||
if (chunkBuffer) {
|
||||
fullText += chunkBuffer;
|
||||
setCompletion(fullText);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
return fullText;
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error("An error occurred");
|
||||
setError(error);
|
||||
toast.error(error.message);
|
||||
setIsLoading(false);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCompletion("");
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
completion,
|
||||
complete,
|
||||
isLoading,
|
||||
error,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export function AISelector({ onOpenChange }: AISelectorProps) {
|
||||
const { editor } = useEditor();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const { completion, complete, isLoading } = useProseCompletion();
|
||||
|
||||
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 dark:prose-invert p-2 px-4">
|
||||
<Markdown>{completion}</Markdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="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,77 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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";
|
||||
import { useReplay } from "~/core/replay";
|
||||
import { TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { Tooltip } from "~/components/ui/tooltip";
|
||||
|
||||
interface GenerativeMenuSwitchProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
const GenerativeMenuSwitch = ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: GenerativeMenuSwitchProps) => {
|
||||
const { editor } = useEditor();
|
||||
const { isReplay } = useReplay();
|
||||
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>
|
||||
{isReplay ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
className="gap-1 rounded-none text-purple-500"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
>
|
||||
<Magic className="h-5 w-5" />
|
||||
Ask AI
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>You can't ask AI in replay mode.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EditorCommand,
|
||||
EditorCommandEmpty,
|
||||
EditorCommandItem,
|
||||
EditorCommandList,
|
||||
EditorContent,
|
||||
type EditorInstance,
|
||||
EditorRoot,
|
||||
ImageResizer,
|
||||
type JSONContent,
|
||||
handleCommandNavigation,
|
||||
handleImageDrop,
|
||||
handleImagePaste,
|
||||
} from "novel";
|
||||
import type { Content } from "@tiptap/react";
|
||||
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 { normalizeMathForEditor, unescapeLatexInMath } from "~/core/utils/markdown";
|
||||
// import { defaultEditorContent } from "./content";
|
||||
|
||||
import "~/styles/prosemirror.css";
|
||||
|
||||
const hljs = require("highlight.js");
|
||||
|
||||
const extensions = [...defaultExtensions, slashCommand];
|
||||
|
||||
export interface ReportEditorProps {
|
||||
content: Content;
|
||||
onMarkdownChange?: (markdown: string) => void;
|
||||
}
|
||||
|
||||
const ReportEditor = ({ content, onMarkdownChange }: ReportEditorProps) => {
|
||||
const [initialContent, setInitialContent] = useState<Content>(() => {
|
||||
// Normalize math delimiters for editor consumption
|
||||
if (typeof content === "string") {
|
||||
return normalizeMathForEditor(content);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState("Saved");
|
||||
|
||||
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) => {
|
||||
if (onMarkdownChange) {
|
||||
let markdown = editor.storage.markdown.getMarkdown();
|
||||
markdown = unescapeLatexInMath(markdown);
|
||||
onMarkdownChange(markdown);
|
||||
}
|
||||
setSaveStatus("Saved");
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
if (!initialContent) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
immediatelyRender={false}
|
||||
initialContent={initialContent as JSONContent}
|
||||
extensions={extensions}
|
||||
className="border-muted relative h-full w-full"
|
||||
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" />
|
||||
<TextButtons />
|
||||
<Separator orientation="vertical" />
|
||||
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
|
||||
<Separator orientation="vertical" />
|
||||
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
|
||||
<Separator orientation="vertical" />
|
||||
<MathSelector />
|
||||
</GenerativeMenuSwitch>
|
||||
</EditorContent>
|
||||
</EditorRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportEditor;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Mathematics } from "novel";
|
||||
|
||||
/**
|
||||
* Extended Mathematics extension with markdown serialization support
|
||||
* Handles both inline math ($...$) and block/display math ($$...$$)
|
||||
*/
|
||||
export const MathematicsWithMarkdown = Mathematics.extend({
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: any, node: any) {
|
||||
const latex = node.attrs?.latex || "";
|
||||
const isBlock = node.attrs?.display === true;
|
||||
|
||||
if (isBlock) {
|
||||
// Block/display math: $$...$$
|
||||
state.write("$$");
|
||||
state.write(latex);
|
||||
state.write("$$");
|
||||
state.closeBlock(node);
|
||||
} else {
|
||||
// Inline math: $...$
|
||||
state.write("$");
|
||||
state.write(latex);
|
||||
state.write("$");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import {
|
||||
CheckSquare,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Text,
|
||||
TextQuote,
|
||||
} from "lucide-react";
|
||||
import { Command, createSuggestionItems, renderItems } from "novel";
|
||||
// import { uploadFn } from "./image-upload";
|
||||
|
||||
export const suggestionItems = createSuggestionItems([
|
||||
{
|
||||
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