deepdata: core product code (backend, frontend, runtime, deepflow module)

This commit is contained in:
figmar
2026-08-30 19:23:38 +08:00
commit bd3db30be9
1405 changed files with 271727 additions and 0 deletions
@@ -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,
},
});