1
0
Code Issues Pull Requests Packages Projects Releases Wiki Activity GitHub Gitee

chore pkg and demo

This commit is contained in:
songjunxi
2023-10-22 10:52:46 +08:00
commit bccff40c0d
59 changed files with 12213 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import { Magic } from "@/ui/icons";
import { PauseCircle } from "lucide-react";
export default function AIGeneratingLoading({ stop }: { stop: () => void }) {
return (
<div className="flex items-center justify-start novel-bg-white shadow-lg rounded-full px-3 py-2 w-16 h-10">
<Magic className="novel-w-7 novel-animate-pulse novel-text-purple-500" />
<span className="text-sm novel-animate-pulse novel-ml-1 novel-text-slate-500">
generating...
</span>
<PauseCircle
onClick={stop}
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-ml-6 novel-w-5 novel-text-stone-300"
/>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import LoadingDots from "@/ui/icons/loading-dots";
import Magic from "@/ui/icons/magic";
import { Editor } from "@tiptap/core";
import { useCompletion } from "ai/react";
import { X, Clipboard, Replace } from "lucide-react";
import { useContext, useEffect, useState } from "react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { NovelContext } from "../../../provider";
type Props = {
editor: Editor;
};
const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
const [isShow, setIsShow] = useState(false);
const { completionApi, plan } = useContext(NovelContext);
const { completion, setCompletion, isLoading, stop } = useCompletion({
id: "novel-edit",
api: `${completionApi}/edit`,
body: { plan },
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
useEffect(() => {
if (completion.length > 0) {
setIsShow(true);
}
}, [completion]);
const handleCopy = () => {
navigator.clipboard.writeText(completion);
};
const handleReplace = () => {
if (completion.length > 0) {
const { from, to } = editor.state.selection;
editor.commands.insertContent(completion, {
updateSelection: true,
});
}
};
return isShow || isLoading ? (
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
{isLoading && (
<div className="novel-mr-auto novel-flex novel-items-center">
<LoadingDots color="#9e9e9e" />
</div>
)}
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
<button>
<Replace
onClick={handleReplace}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<button>
<Clipboard
onClick={handleCopy}
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<X
onClick={() => {
setIsShow(false);
setCompletion("");
}}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</div>
</div>
{completion.length > 0 && (
<div className="novel-text-sm mt-2">{completion}</div>
)}
</div>
</div>
) : null;
};
export default AIEditorBubble;

View File

@@ -0,0 +1,166 @@
import { Editor } from "@tiptap/core";
import {
Beef,
Book,
CheckCheck,
ChevronDown,
Heading1,
LayoutPanelTop,
ListMinus,
ListPlus,
PartyPopper,
PauseCircle,
Pipette,
Repeat,
Scissors,
Wand,
} from "lucide-react";
import { FC, useContext, useEffect } from "react";
import { Command } from "cmdk";
import Magic from "@/ui/icons/magic";
import { useCompletion } from "ai/react";
import { NovelContext } from "../../../provider";
interface AISelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export const AISelector: FC<AISelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items = [
{
name: "Improve writing",
detail: "Improve writing",
icon: Wand,
},
{
name: "Fix spelling & grammar",
detail:
"Please correct spelling and grammar errors in the following text",
icon: CheckCheck,
},
{
name: "Make shorter",
detail: "Make shorter",
icon: ListMinus,
},
{
name: "Make longer",
detail: "Make longer",
icon: ListPlus,
},
{
name: "Writing suggestions",
detail: "Provide suggestions and improvements for the writing",
icon: Beef,
},
{
name: "Enhance vocabulary",
detail: "Suggest synonyms and expand vocabulary usage",
icon: Book,
},
{
name: "Generate titles",
detail: "Automatically generate compelling titles for the content",
icon: Heading1,
},
{
name: "Templates & structure",
detail:
"Offer templates and structure suggestions to improve the writing organization",
icon: LayoutPanelTop,
},
{
name: "Fix repetitive",
detail: "Identify and fix repetitive words or phrases in the content",
icon: Scissors,
},
{
name: "Adjust writing style",
detail:
"Suggest adjustments to writing style and tone based on the target audience",
icon: PartyPopper,
},
];
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
e.preventDefault();
}
};
if (isOpen) {
document.addEventListener("keydown", onKeyDown);
} else {
document.removeEventListener("keydown", onKeyDown);
}
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "novel-edit",
api: `${completionApi}/edit`,
body: { plan },
});
return (
<div className="novel-relative novel-h-full">
<div className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-text-sm novel-font-medium novel-text-purple-500 hover:novel-bg-stone-100 active:novel-bg-stone-200">
<button
className="novel-p-2 novel-flex novel-h-full novel-items-center novel-gap-1"
onClick={() => {
if (isLoading) {
stop();
}
setIsOpen(!isOpen);
}}>
<Magic className="novel-h-4 novel-w-4" />
<span className="novel-whitespace-nowrap">Ask AI</span>
{!isLoading ? (
<ChevronDown className="novel-h-4 novel-w-4" />
) : (
<PauseCircle
onClick={stop}
className="novel-h-4 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
/>
)}
</button>
</div>
{isOpen && (
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<Command.List>
{items.map((item, index) => (
<Command.Item
key={index}
onSelect={() => {
if (!isLoading) {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, " ");
complete(`${item.detail}:\n ${text}`);
setIsOpen(false);
}
}}
className="novel-flex group novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
<div className="novel-flex novel-items-center novel-space-x-2">
<item.icon className="novel-h-4 novel-w-4 novel-text-purple-500" />
<span>{item.name}</span>
</div>
{/* <CornerDownLeft className="novel-hidden novel-h-4 novel-w-4 group-hover:novel-block" /> */}
</Command.Item>
))}
</Command.List>
</Command>
)}
</div>
);
};

View File

@@ -0,0 +1,93 @@
import LoadingDots from "@/ui/icons/loading-dots";
import Magic from "@/ui/icons/magic";
import { Editor } from "@tiptap/core";
import { useCompletion } from "ai/react";
import { X, Clipboard, Replace } from "lucide-react";
import { useContext, useEffect, useState } from "react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { NovelContext } from "../../../provider";
type Props = {
editor: Editor;
};
const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
const [isShow, setIsShow] = useState(false);
const { completionApi, plan } = useContext(NovelContext);
const { completion, setCompletion, isLoading, stop } = useCompletion({
id: "novel-translate",
api: `${completionApi}/translate`,
body: { plan },
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
useEffect(() => {
if (completion.length > 0) {
setIsShow(true);
}
}, [completion]);
const handleCopy = () => {
navigator.clipboard.writeText(completion);
};
const handleReplace = () => {
if (completion.length > 0) {
const { from, to } = editor.state.selection;
editor.commands.insertContent(completion, {
updateSelection: true,
});
}
};
return isShow || isLoading ? (
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
{isLoading && (
<div className="novel-mr-auto novel-flex novel-items-center">
<LoadingDots color="#9e9e9e" />
</div>
)}
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
<button>
<Replace
onClick={handleReplace}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<button>
<Clipboard
onClick={handleCopy}
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<X
onClick={() => {
setIsShow(false);
setCompletion("");
}}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</div>
</div>
{completion.length > 0 && (
<div className="novel-text-sm mt-2">{completion}</div>
)}
</div>
</div>
) : null;
};
export default AITranslateBubble;

View File

@@ -0,0 +1,129 @@
import { Editor } from "@tiptap/core";
import { Globe2, Languages, PauseCircle } from "lucide-react";
import { FC, useContext, useEffect } from "react";
import { Command } from "cmdk";
import { useCompletion } from "ai/react";
import { NovelContext } from "../../../provider";
interface TranslateSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export const TranslateSelector: FC<TranslateSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items = [
{
name: "English",
detail: "Translate into English",
},
{
name: "Chinese",
detail: "Translate into Chinese",
},
{
name: "Spanish",
detail: "Translate into Spanish",
},
{
name: "French",
detail: "Translate into French",
},
{
name: "German",
detail: "Translate into German",
},
{
name: "Japanese",
detail: "Translate into Japanese",
},
{
name: "Russian",
detail: "Translate into Russian",
},
{
name: "Korean",
detail: "Translate into Korean",
},
{
name: "Arabic",
detail: "Translate into Arabic",
},
{
name: "Portuguese",
detail: "Translate into Portuguese",
},
];
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
e.preventDefault();
}
};
if (isOpen) {
document.addEventListener("keydown", onKeyDown);
} else {
document.removeEventListener("keydown", onKeyDown);
}
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "novel-translate",
api: `${completionApi}/translate`,
body: { plan },
});
return (
<div className="novel-relative novel-h-full">
<div className="novel-flex novel-h-full novel-items-center novel-text-sm novel-font-medium hover:novel-bg-stone-100 active:novel-bg-stone-200">
{isLoading ? (
<button className="p-2">
<PauseCircle
onClick={stop}
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
/>
</button>
) : (
<button className="p-2">
<Languages
onClick={() => setIsOpen(!isOpen)}
className="novel-h-5 novel-text-stone-600 novel-w-4"
/>
</button>
)}
</div>
{isOpen && (
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-28 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<Command.List>
{items.map((item, index) => (
<Command.Item
key={index}
onSelect={() => {
if (!isLoading) {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, " ");
complete(`${item.detail}:\n ${text}`);
setIsOpen(false);
}
}}
className="novel-flex novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
<span>{item.name}</span>
</Command.Item>
))}
</Command.List>
</Command>
)}
</div>
);
};

View File

@@ -0,0 +1,192 @@
import { Editor } from "@tiptap/core";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import * as Popover from "@radix-ui/react-popover";
export interface BubbleColorMenuItem {
name: string;
color: string;
}
interface ColorSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--inke-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(--inke-highlight-default)",
},
{
name: "Purple",
color: "var(--inke-highlight-purple)",
},
{
name: "Red",
color: "var(--inke-highlight-red)",
},
{
name: "Yellow",
color: "var(--inke-highlight-yellow)",
},
{
name: "Blue",
color: "var(--inke-highlight-blue)",
},
{
name: "Green",
color: "var(--inke-highlight-green)",
},
{
name: "Orange",
color: "var(--inke-highlight-orange)",
},
{
name: "Pink",
color: "var(--inke-highlight-pink)",
},
{
name: "Gray",
color: "var(--inke-highlight-gray)",
},
];
export const ColorSelector: FC<ColorSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color })
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color })
);
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span
className="novel-rounded-sm novel-px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}>
A
</span>
{/* <ChevronDown className="novel-h-4 novel-w-4" /> */}
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Color
</div>
{TEXT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
style={{ color }}>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("textStyle", { color }) && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
<div className="novel-mb-1 novel-mt-2 novel-px-2 novel-text-sm novel-text-stone-500">
Background
</div>
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
style={{ backgroundColor: color }}>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@@ -0,0 +1,195 @@
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
import { FC, useState } from "react";
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import { NodeSelector } from "./node-selector";
import { ColorSelector } from "./color-selector";
import { LinkSelector } from "./link-selector";
import { cn } from "@/lib/utils";
import { TableSelector } from "./table-selector";
import { AISelector } from "./ai-selectors/edit/ai-edit-selector";
import { TranslateSelector } from "./ai-selectors/translate/ai-translate-selector";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor!.isActive("bold"),
command: () => props.editor!.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor!.isActive("italic"),
command: () => props.editor!.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor!.isActive("underline"),
command: () => props.editor!.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor!.isActive("strike"),
command: () => props.editor!.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor!.isActive("code"),
command: () => props.editor!.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
// don't show bubble menu if:
// - the selected node is an image
// - the selection is empty
// - the selection is a node selection (for drag handles)
if (editor.isActive("image") || empty || isNodeSelection(selection)) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isTableSelectorOpen, setIsTableSelectorOpen] = useState(false);
const [isAISelectorOpen, setIsAISelectorOpen] = useState(false);
const [isTranslateSelectorOpen, setIsTranslateSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="novel-flex novel-w-fit novel-max-w-[97vw] novel-overflow-x-auto novel-divide-x novel-divide-stone-200 novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl">
{props.editor && (
<>
<AISelector
editor={props.editor}
isOpen={isAISelectorOpen}
setIsOpen={() => {
setIsAISelectorOpen(!isAISelectorOpen);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
{props.editor.isActive("table") && (
<TableSelector
editor={props.editor}
isOpen={isTableSelectorOpen}
setIsOpen={() => {
setIsTableSelectorOpen(!isTableSelectorOpen);
setIsColorSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<div className="novel-flex">
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className="novel-p-2 novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
type="button">
<item.icon
className={cn("novel-h-4 novel-w-4", {
"novel-text-blue-500": item.isActive(),
})}
/>
</button>
))}
</div>
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsTableSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<TranslateSelector
editor={props.editor}
isOpen={isTranslateSelectorOpen}
setIsOpen={() => {
setIsTranslateSelectorOpen(!isTranslateSelectorOpen);
setIsAISelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
</>
)}
</BubbleMenu>
);
};

View File

@@ -0,0 +1,83 @@
import { cn, getUrlFromString } from "@/lib/utils";
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
// Autofocus on input by default
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
return (
<div className="novel-relative">
<button
type="button"
className="novel-flex novel-h-full novel-items-center novel-space-x-2 novel-px-3 novel-py-1.5 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => {
setIsOpen(!isOpen);
}}
>
<p className="novel-text-base"></p>
<p
className={cn(
"novel-underline novel-decoration-stone-400 novel-underline-offset-4",
{
"novel-text-blue-500": editor.isActive("link"),
}
)}
>
Link
</p>
</button>
{isOpen && (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget[0] as HTMLInputElement;
const url = getUrlFromString(input.value);
url && editor.chain().focus().setLink({ href: url }).run();
setIsOpen(false);
}}
className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-flex novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1"
>
<input
ref={inputRef}
type="text"
placeholder="Paste a link"
className="novel-flex-1 novel-bg-white novel-p-1 novel-text-sm novel-outline-none"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="novel-flex novel-items-center novel-rounded-sm novel-p-1 novel-text-red-600 novel-transition-all hover:novel-bg-red-100 dark:hover:novel-bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsOpen(false);
}}
>
<Trash className="novel-h-4 novel-w-4" />
</button>
) : (
<button className="novel-flex novel-items-center novel-rounded-sm novel-p-1 novel-text-stone-600 novel-transition-all hover:novel-bg-stone-100">
<Check className="novel-h-4 novel-w-4" />
</button>
)}
</form>
)}
</div>
);
};

View File

@@ -0,0 +1,140 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
Heading4,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from ".";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").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.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "Heading 1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-whitespace-nowrap novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.command();
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="flex items-center space-x-2">
<div className="novel-flex novel-items-center novel-space-x-2">
{" "}
<item.icon className="novel-h-3 novel-w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@@ -0,0 +1,163 @@
import { Editor } from "@tiptap/core";
import {
ChevronDown,
LucideIcon,
Rows,
PanelTop,
PanelBottom,
PanelRight,
PanelLeft,
Trash2,
SplitSquareHorizontal,
Heading1,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import * as Popover from "@radix-ui/react-popover";
export interface BubbleTableMenuItem {
name: string;
icon: LucideIcon;
action: () => boolean;
}
interface TableSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const TABLE_COLUMN_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Add column before",
icon: PanelLeft,
action: () => editor.chain().focus().addColumnBefore().run(),
},
{
name: "Add column after",
icon: PanelRight,
action: () => editor.chain().focus().addColumnAfter().run(),
},
{
name: "Delete column",
icon: Trash2,
action: () => editor.chain().focus().deleteColumn().run(),
},
];
};
const TABLE_ROW_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Add row before",
icon: PanelTop,
action: () => editor.chain().focus().addRowBefore().run(),
},
{
name: "Add row after",
icon: PanelBottom,
action: () => editor.chain().focus().addRowAfter().run(),
},
{
name: "Delete row",
icon: Trash2,
action: () => editor.chain().focus().deleteRow().run(),
},
];
};
const TABLE_CELL_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Merge or split",
icon: SplitSquareHorizontal,
action: () => editor.chain().focus().mergeOrSplit().run(),
},
{
name: "Toggle header cell",
icon: Heading1,
action: () => editor.chain().focus().toggleHeaderCell().run(),
},
{
name: "Delete table",
icon: Trash2,
action: () => editor.chain().focus().deleteTable().run(),
},
];
};
export const TableSelector: FC<TableSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span className="novel-rounded-sm novel-px-1">Table</span>
<ChevronDown className="novel-h-4 novel-w-4" />
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Column
</div>
{TABLE_COLUMN_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Row
</div>
{TABLE_ROW_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Cell
</div>
{TABLE_CELL_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@@ -0,0 +1,10 @@
export const defaultEditorContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "What's New" }],
},
],
};

View File

@@ -0,0 +1,56 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
// eslint-disable-next-line no-unused-vars
interface Commands<ReturnType> {
customkeymap: {
/**
* Select text between node boundaries
*/
selectTextWithinNodeBoundaries: () => ReturnType;
};
}
}
const CustomKeymap = Extension.create({
name: "CustomKeymap",
addCommands() {
return {
selectTextWithinNodeBoundaries:
() =>
({ editor, commands }) => {
const { state } = editor;
const { tr } = state;
const startNodePos = tr.selection.$from.start();
const endNodePos = tr.selection.$to.end();
return commands.setTextSelection({
from: startNodePos,
to: endNodePos,
});
},
};
},
addKeyboardShortcuts() {
return {
"Mod-a": ({ editor }) => {
const { state } = editor;
const { tr } = state;
const startSelectionPos = tr.selection.from;
const endSelectionPos = tr.selection.to;
const startNodePos = tr.selection.$from.start();
const endNodePos = tr.selection.$to.end();
const isCurrentTextSelectionNotExtendedToNodeBoundaries =
startSelectionPos > startNodePos || endSelectionPos < endNodePos;
if (isCurrentTextSelectionNotExtendedToNodeBoundaries) {
editor.chain().selectTextWithinNodeBoundaries().run();
return true;
}
return false;
},
};
},
});
export default CustomKeymap;

View File

@@ -0,0 +1,213 @@
import { Extension } from "@tiptap/core";
import { NodeSelection, Plugin } from "@tiptap/pm/state";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface DragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
].join(", ")
)
);
}
function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
}
function DragHandle(options: DragHandleOptions) {
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (nodePos == null || nodePos < 0) return;
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
view.dom.classList.remove("dragging");
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (!nodePos) return;
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hidden");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hidden");
}
}
return new Plugin({
view: (view) => {
dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
dragHandleElement.addEventListener("dragstart", (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
});
hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view) => {
view.dom.classList.remove("dragging");
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
interface DragAndDropOptions {}
const DragAndDrop = Extension.create<DragAndDropOptions>({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
}),
];
},
});
export default DragAndDrop;

View File

@@ -0,0 +1,51 @@
import { Extension, textInputRule } from "@tiptap/core";
const Glyphs = Extension.create({
name: "Glyphs",
addInputRules() {
return [
// Emoji Shortcodes
textInputRule({ find: /:heart:$/, replace: "❤️ " }),
textInputRule({ find: /:heart_hands:$/, replace: "🫶 " }),
textInputRule({ find: /:sparkles:$/, replace: "✨ " }),
textInputRule({ find: /:party:$/, replace: "🎉 " }),
textInputRule({ find: /:fire:$/, replace: "🔥 " }),
textInputRule({ find: /:100:$/, replace: "💯 " }),
textInputRule({ find: /:poop:$/, replace: "💩 " }),
textInputRule({ find: /:eyes:$/, replace: "👀 " }),
textInputRule({ find: /:ghost:$/, replace: "👻 " }),
textInputRule({ find: /:graduation_cap:$/, replace: "🎓 " }),
textInputRule({ find: /:thumbsup:$/, replace: "👍 " }),
textInputRule({ find: /:thumbsdown:$/, replace: "👎 " }),
textInputRule({ find: /:rocket:$/, replace: "🚀 " }),
textInputRule({ find: /:salute:$/, replace: "👋 " }),
textInputRule({ find: /:grinning_face:$/, replace: "😀 " }),
textInputRule({ find: /:sweat_smile:$/, replace: "😅 " }),
textInputRule({ find: /:droplet:$/, replace: "💧 " }),
textInputRule({ find: /:starstruck:$/, replace: "🤩 " }),
textInputRule({ find: /:sob:$/, replace: "😭 " }),
textInputRule({ find: /:skull:$/, replace: "💀 " }),
textInputRule({ find: /:smile:$/, replace: "😄 " }),
textInputRule({ find: /:rofl:$/, replace: "🤣 " }),
textInputRule({ find: /:wink:$/, replace: "😉 " }),
textInputRule({ find: /:candle:$/, replace: "🕯️ " }),
textInputRule({ find: /:diya_lamp:$/, replace: "🪔 " }),
textInputRule({ find: /:rainbow:$/, replace: "🌈 " }),
textInputRule({ find: /:om:$/, replace: "🕉️ " }),
textInputRule({ find: /:bulb:$/, replace: "💡 " }),
textInputRule({ find: /:dizzy:$/, replace: "💫 " }),
textInputRule({ find: /:bomb:$/, replace: "💣 " }),
textInputRule({ find: /:firecracker:$/, replace: "🧨 " }),
textInputRule({ find: /:fireworks:$/, replace: "🎆 " }),
textInputRule({ find: /:alien:$/, replace: "👽 " }),
textInputRule({ find: /:robot:$/, replace: "🤖 " }),
textInputRule({ find: /:crystal_ball:$/, replace: "🔮 " }),
textInputRule({ find: /:chocolate_bar:$/, replace: "🍫 " }),
textInputRule({ find: /:unicorn:$/, replace: "🦄 " }),
textInputRule({ find: /:clown_face:$/, replace: "🤡 " }),
];
},
});
export default Glyphs;

View File

@@ -0,0 +1,71 @@
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(
".ProseMirror-selectednode"
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
});
editor.commands.setNodeSelection(selection.from);
}
};
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
container={null}
origin={false}
/* Resize event edges */
edge={false}
throttleDrag={0}
/* When resize or scale, keeps a ratio of the width, height. */
keepRatio={true}
/* resizable*/
/* Only one of resizable, scalable, warpable can be used. */
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
// dist,
delta,
}: // direction,
// clientX,
// clientY,
any) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
}}
// { target, isDrag, clientX, clientY }: any
onResizeEnd={() => {
updateMediaSize();
}}
/* scalable */
/* Only one of resizable, scalable, warpable can be used. */
scalable={true}
throttleScale={0}
/* Set the direction of resizable */
renderDirections={["w", "e"]}
onScale={({
target,
// scale,
// dist,
// delta,
transform,
}: // clientX,
// clientY,
any) => {
target!.style.transform = transform;
}}
/>
</>
);
};

View File

@@ -0,0 +1,179 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import TiptapImage from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import Typography from "@tiptap/extension-typography";
import SlashCommand from "./slash-command";
import { InputRule } from "@tiptap/core";
import UploadImagesPlugin from "@/ui/editor/plugins/upload-images";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { createLowlight, common } from "lowlight";
import markdown from "highlight.js/lib/languages/markdown";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
import html from "highlight.js/lib/languages/xml";
import Table from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import Youtube from "@tiptap/extension-youtube";
import UpdatedImage from "./updated-image";
import CustomKeymap from "./custom-keymap";
import DragAndDrop from "./drag-and-drop";
import Glyphs from "./glyphs";
const lowlight = createLowlight(common);
lowlight.register({ markdown });
lowlight.register({ html });
lowlight.register({ css });
lowlight.register({ js });
lowlight.register({ ts });
export const defaultExtensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
bulletList: {
HTMLAttributes: {
class: "novel-list-disc novel-list-outside novel-leading-3 novel--mt-2",
},
},
orderedList: {
HTMLAttributes: {
class:
"novel-list-decimal novel-list-outside novel-leading-3 novel--mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "novel-leading-normal novel--mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "novel-border-l-4 novel-border-stone-700",
},
},
code: {
HTMLAttributes: {
class:
"novel-rounded-md novel-bg-stone-200 novel-px-1.5 novel-py-1 novel-font-mono novel-font-medium novel-text-stone-900",
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
}),
// patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range }) => {
const attributes = {};
const { tr } = state;
const start = range.from;
let end = range.to;
tr.insert(start - 1, this.type.create(attributes)).delete(
tr.mapping.map(start),
tr.mapping.map(end)
);
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "novel-mt-4 novel-mb-6 novel-border-t novel-border-stone-300",
},
}),
TiptapLink.configure({
HTMLAttributes: {
class:
"novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer",
},
}),
TiptapImage.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin()];
},
}).configure({
allowBase64: true,
HTMLAttributes: {
class: "novel-rounded-lg novel-border novel-border-stone-200",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "novel-rounded-lg novel-border novel-border-stone-200",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands, or '??' for AI autocomplete...";
},
includeChildren: true,
}),
SlashCommand,
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "novel-not-prose novel-pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "novel-flex novel-items-start novel-my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
transformPastedText: true,
}),
CodeBlockLowlight.configure({
lowlight,
}),
Table.configure({
resizable: true,
allowTableNodeSelection: true,
}),
Youtube.configure({
origin: "inke.app",
controls: true,
inline: false,
}),
TableRow,
TableHeader,
TableCell,
Typography,
CustomKeymap,
DragAndDrop,
Glyphs,
];

View File

@@ -0,0 +1,485 @@
import React, {
useState,
useEffect,
useCallback,
ReactNode,
useRef,
useLayoutEffect,
useContext,
} from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import { useCompletion } from "ai/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
Image as ImageIcon,
Code,
CheckSquare,
Table2,
PauseCircle,
} from "lucide-react";
import { LoadingCircle } from "@/ui/icons";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { Magic } from "@/ui/icons";
import { getPrevText } from "@/lib/editor";
import { startImageUpload } from "@/ui/editor/plugins/upload-images";
import { NovelContext } from "../provider";
import { Youtube } from "lucide-react";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: any;
}) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = ({
query,
plan,
}: {
query: string;
plan: number;
}) => {
return [
{
title: "Continue writing",
description: "Use AI to expand your thoughts.",
searchTerms: ["gpt"],
icon: <Magic className="novel-w-7" />,
},
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
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 }: CommandProps) => {
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 }: CommandProps) => {
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 }: CommandProps) => {
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 }: CommandProps) => {
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 }: CommandProps) => {
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 }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
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 }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Table",
description: "Create a 2x2 table.",
searchTerms: ["table", "cell", "row"],
icon: <Table2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 2, cols: 2, withHeaderRow: true })
.run();
},
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
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];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos);
}
};
input.click();
},
},
{
title: "Youtube video",
description: "Play the Youtube video you filled out.",
searchTerms: ["video", "ytb", "Youtube", "youtube"],
icon: <Youtube size={19} />,
command: ({ editor, range }: CommandProps) => {
const url = prompt(
"Enter YouTube URL",
"https://www.youtube.com/watch?v="
);
if (url) {
editor
.chain()
.focus()
.deleteRange(range)
.setYoutubeVideo({
src: url,
})
.run();
}
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
};
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
editor,
range,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "ai-continue",
api: `${completionApi}/continue`,
body: { plan },
onResponse: (response) => {
if (response.status === 429) {
toast.error("You have reached your request limit for the day.");
va.track("Rate Limit Reached");
return;
}
editor.chain().focus().deleteRange(range).run();
},
onFinish: (_prompt, completion) => {
// highlight the generated text
editor.commands.setTextSelection({
from: range.from,
to: range.from + completion.length,
});
},
onError: (e) => {
if (e.message !== "Failed to fetch") {
toast.error(e.message);
}
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
va.track("Slash Command Used", {
command: item.title,
});
if (item) {
if (item.title === "Continue writing") {
if (isLoading) return;
complete(
getPrevText(editor, {
chars: 5000,
offset: 1,
})
);
} else {
command(item);
}
}
},
[complete, isLoading, command, editor, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="novel-z-50 novel-h-auto novel-max-h-[330px] novel-w-72 novel-overflow-y-auto novel-rounded-md novel-border novel-border-stone-200 novel-bg-white novel-px-1 novel-py-2 novel-shadow-md novel-transition-all">
{items.map((item: CommandItemProps, index: number) => {
return (
<button
className={`novel-flex novel-w-full novel-items-center novel-space-x-2 novel-rounded-md novel-px-2 novel-py-1 novel-text-left novel-text-sm novel-text-stone-900 hover:novel-bg-stone-100 ${
index === selectedIndex
? "novel-bg-stone-100 novel-text-stone-900"
: ""
}`}
key={index}
onClick={() => selectItem(index)}>
<div className="novel-flex novel-h-10 novel-w-10 novel-items-center novel-justify-center novel-rounded-md novel-border novel-border-stone-200 novel-bg-white">
{item.title === "Continue writing" && isLoading ? (
<LoadingCircle />
) : (
item.icon
)}
</div>
<div>
<p className="novel-font-medium">{item.title}</p>
<p className="novel-text-xs novel-text-stone-500">
{item.description}
</p>
</div>
{item.title === "Continue writing" && isLoading && (
<div>
<PauseCircle
className="novel-h-5 novel-w-5 novel-text-stone-300 hover:novel-text-stone-500 novel-cursor-pointer"
onClick={stop}
/>
</div>
)}
</button>
);
})}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
const SlashCommand = Command.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export default SlashCommand;

View File

@@ -0,0 +1,17 @@
import Image from "@tiptap/extension-image";
const UpdatedImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
},
height: {
default: null,
},
};
},
});
export default UpdatedImage;

View File

@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
import { defaultEditorProps } from "./props";
import { defaultExtensions } from "./extensions";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { useDebouncedCallback } from "use-debounce";
import { useCompletion } from "ai/react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { defaultEditorContent } from "./default-content";
import { EditorBubbleMenu } from "./bubble-menu";
import { getPrevText } from "@/lib/editor";
import { ImageResizer } from "./extensions/image-resizer";
import { EditorProps } from "@tiptap/pm/view";
import { Editor as EditorClass, Extensions } from "@tiptap/core";
import { NovelContext } from "./provider";
import "./styles.css";
import AIEditorBubble from "./bubble-menu/ai-selectors/edit/ai-edit-bubble";
import AIGeneratingLoading from "./bubble-menu/ai-selectors/ai-loading";
import AITranslateBubble from "./bubble-menu/ai-selectors/translate/ai-translate-bubble";
export default function Editor({
completionApi = "/api/generate",
className = "novel-relative novel-min-h-[500px] novel-w-full novel-max-w-screen-lg novel-border-stone-200 novel-bg-white sm:novel-mb-[calc(20vh)] sm:novel-rounded-lg sm:novel-border sm:novel-shadow-lg",
defaultValue = defaultEditorContent,
extensions = [],
editorProps = {},
onUpdate = () => {},
onDebouncedUpdate = () => {},
debounceDuration = 750,
storageKey = "novel__content",
disableLocalStorage = false,
editable = true,
plan = "5",
}: {
/**
* The API route to use for the OpenAI completion API.
* Defaults to "/api/generate".
*/
completionApi?: string;
/**
* Additional classes to add to the editor container.
* Defaults to "relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg".
*/
className?: string;
/**
* The default value to use for the editor.
* Defaults to defaultEditorContent.
*/
defaultValue?: JSONContent | string;
/**
* A list of extensions to use for the editor, in addition to the default inke extensions.
* Defaults to [].
*/
extensions?: Extensions;
/**
* Props to pass to the underlying Tiptap editor, in addition to the default inke editor props.
* Defaults to {}.
*/
editorProps?: EditorProps;
/**
* A callback function that is called whenever the editor is updated.
* Defaults to () => {}.
*/
// eslint-disable-next-line no-unused-vars
onUpdate?: (editor?: EditorClass) => void;
/**
* A callback function that is called whenever the editor is updated, but only after the defined debounce duration.
* Defaults to () => {}.
*/
// eslint-disable-next-line no-unused-vars
onDebouncedUpdate?: (
json: JSONContent,
text: string,
markdown: string,
editor: EditorClass
) => void;
/**
* The duration (in milliseconds) to debounce the onDebouncedUpdate callback.
* Defaults to 750.
*/
debounceDuration?: number;
/**
* The key to use for storing the editor's value in local storage.
* Defaults to "novel__content".
*/
storageKey?: string;
/**
* Disable local storage read/save.
* Defaults to false.
*/
disableLocalStorage?: boolean;
/**
* Enable editing.
* Defaults to true.
*/
editable?: boolean;
plan?: string;
}) {
const [content, setContent] = useLocalStorage(storageKey, defaultValue);
const [hydrated, setHydrated] = useState(false);
const [isLoadingOutside, setLoadingOutside] = useState(false);
const debouncedUpdates = useDebouncedCallback(async ({ editor }) => {
const json = editor.getJSON();
const text = editor.getText();
const markdown = editor.storage.markdown.getMarkdown();
onDebouncedUpdate(json, text, markdown, editor);
if (!disableLocalStorage) {
setContent(json);
}
}, debounceDuration);
const editor = useEditor({
extensions: [...defaultExtensions, ...extensions],
editorProps: {
...defaultEditorProps,
...editorProps,
},
editable: editable,
onUpdate: (e) => {
const selection = e.editor.state.selection;
const lastTwo = getPrevText(e.editor, {
chars: 2,
});
if (lastTwo === "??" && !isLoading) {
setLoadingOutside(true);
e.editor.commands.deleteRange({
from: selection.from - 2,
to: selection.from,
});
complete(
getPrevText(e.editor, {
chars: 5000,
})
);
va.track("Autocomplete Shortcut Used");
} else {
onUpdate(e.editor);
debouncedUpdates(e);
}
},
autofocus: false,
});
const { complete, completion, isLoading, stop } = useCompletion({
id: "ai-continue",
api: `${completionApi}/continue`,
body: { plan },
onFinish: (_prompt, completion) => {
editor?.commands.setTextSelection({
from: editor.state.selection.from - completion.length,
to: editor.state.selection.from,
});
},
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
const prev = useRef("");
// Insert chunks of the generated text
useEffect(() => {
const diff = completion.slice(prev.current.length);
prev.current = completion;
editor?.commands.insertContent(diff);
if (!isLoading) {
setLoadingOutside(false);
}
}, [isLoading, editor, completion]);
// Default: Hydrate the editor with the content from localStorage.
// If disableLocalStorage is true, hydrate the editor with the defaultValue.
useEffect(() => {
if (!editor || hydrated) return;
const value = disableLocalStorage ? defaultValue : content;
if (value) {
editor.commands.setContent(value);
setHydrated(true);
}
}, [editor, defaultValue, content, hydrated, disableLocalStorage]);
return (
<NovelContext.Provider
value={{
completionApi,
plan,
}}>
<div
onClick={() => {
editor?.chain().focus().run();
}}
className={className}>
{editor && (
<>
<EditorBubbleMenu editor={editor} />
<AIEditorBubble editor={editor} />
<AITranslateBubble editor={editor} />
</>
)}
{editor?.isActive("image") && <ImageResizer editor={editor} />}
<EditorContent editor={editor} />
{isLoadingOutside && isLoading && (
<div className="novel-fixed novel-bottom-3 novel-right-3">
<AIGeneratingLoading stop={stop} />
</div>
)}
</div>
</NovelContext.Provider>
);
}

View File

@@ -0,0 +1,158 @@
import { BlobResult } from "@vercel/blob";
import { toast } from "sonner";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { NovelContext } from "../provider";
import { useContext } from "react";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(this);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute(
"class",
"opacity-40 rounded-lg border border-stone-200"
);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(
set.find(null, null, (spec) => spec.id == action.remove.id)
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(null, null, (spec) => spec.id == id);
return found.length ? found[0].from : null;
}
export function startImageUpload(file: File, view: EditorView, pos: number) {
// check if the file is an image
if (!file.type.includes("image/")) {
toast.error("File type not supported.");
return;
} else if (file.size / 1024 / 1024 > 5) {
toast.error(`File size too big (max ${15}MB).`);
return;
}
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
handleImageUpload(file).then((src) => {
const { schema } = view.state;
let pos = findPlaceholder(view.state, id);
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
// When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read
// the image locally
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
});
}
export const handleImageUpload = (file: File) => {
// upload to Vercel Blob
return new Promise((resolve) => {
toast.promise(
fetch("/api/upload", {
method: "POST",
headers: {
"content-type": file?.type || "application/octet-stream",
"x-vercel-filename": file?.name || "image.png",
},
body: file,
}).then(async (res) => {
// Successfully uploaded image
if (res.status === 200) {
const { url } = (await res.json()) as BlobResult;
// preload the image
let 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 if (res.status === 429) {
resolve(file);
throw new Error(
"You have exceeded the maximum size of uploads, please upgrade your plan."
);
} else {
throw new Error(`Error uploading image. Please try again.`);
}
}),
{
loading: "Uploading image...",
success: "Image uploaded successfully.",
error: (e) => e.message,
}
);
});
};

View File

@@ -0,0 +1,53 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "@/ui/editor/plugins/upload-images";
export const defaultEditorProps: EditorProps = {
attributes: {
class: `novel-prose-lg novel-prose-stone dark:novel-prose-invert prose-headings:novel-font-title novel-font-default focus:novel-outline-none novel-max-w-full`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
startImageUpload(file, view, coordinates?.pos || 0 - 1);
return true;
}
return false;
},
};

View File

@@ -0,0 +1,11 @@
"use client";
import { createContext } from "react";
export const NovelContext = createContext<{
completionApi: string;
plan: string;
}>({
completionApi: "/api/generate",
plan: "5",
});

View File

@@ -0,0 +1,164 @@
.tiptap pre {
background: #000;
border-radius: 0.5rem;
color: #fff;
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
overflow: hidden;
}
.tiptap pre .hljs-emphasis {
font-style: italic;
}
.tiptap pre .hljs-strong {
font-weight: 700;
}
.tiptap pre code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
.tiptap pre .hljs-comment,
.tiptap pre .hljs-quote {
color: #616161;
}
.tiptap pre .hljs-variable,
.tiptap pre .hljs-template-variable,
.tiptap pre .hljs-attribute,
.tiptap pre .hljs-tag,
.tiptap pre .hljs-name,
.tiptap pre .hljs-regexp,
.tiptap pre .hljs-link,
.tiptap pre .hljs-name,
.tiptap pre .hljs-selector-id,
.tiptap pre .hljs-selector-class {
color: #f98181;
}
.tiptap pre .hljs-number,
.tiptap pre .hljs-meta,
.tiptap pre .hljs-built_in,
.tiptap pre .hljs-builtin-name,
.tiptap pre .hljs-literal,
.tiptap pre .hljs-type,
.tiptap pre .hljs-params {
color: #fbbc88;
}
.tiptap pre .hljs-string,
.tiptap pre .hljs-symbol,
.tiptap pre .hljs-bullet {
color: #b9f18d;
}
.tiptap pre .hljs-title,
.tiptap pre .hljs-section {
color: #faf594;
}
.tiptap pre .hljs-keyword,
.tiptap pre .hljs-selector-tag {
color: #70cff8;
}
/* table */
.tiptap table {
margin: 0;
overflow: hidden;
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border-radius: 2px;
}
.tiptap table td,
.tiptap table th {
border: 1px solid #ced4da;
box-sizing: border-box;
min-width: 1em;
padding: 6px 6px;
position: relative;
vertical-align: top;
/* > * {
margin-bottom: 0;
} */
}
.tiptap table th {
background-color: rgb(248, 248, 248);
color: #000;
font-weight: bold;
text-align: left;
}
.tiptap table .selectedCell:after {
background: rgba(200, 200, 255, 0.4);
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: #adf;
bottom: -2px;
position: absolute;
right: -2px;
pointer-events: none;
top: 0;
width: 4px;
}
.tiptap table p {
margin: 0;
overflow: hidden;
}
.tableWrapper {
padding: 1rem 0;
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
/* youtube */
.tiptap iframe {
border: 1px solid #c4c4c400;
border-radius: 6px;
min-width: 200px;
min-height: 180px;
width: 100%;
display: block;
outline: 0px solid transparent;
box-shadow: 1px 1px 10px #72727236;
}
.tiptap iframe:before {
content: "";
display: inline-block;
padding-bottom: 100%;
width: 0.1px;
vertical-align: middle;
}
.tiptap div[data-youtube-video] {
cursor: move;
padding-right: 24px;
width: 100%;
}
.tiptap .ProseMirror-selectednode iframe {
transition: outline 0.15s;
outline: 3px solid #3c69ff;
}