chore pkg and demo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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;
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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;
|
@@ -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>
|
||||
);
|
||||
};
|
192
packages/core/src/ui/editor/bubble-menu/color-selector.tsx
Normal file
192
packages/core/src/ui/editor/bubble-menu/color-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
195
packages/core/src/ui/editor/bubble-menu/index.tsx
Normal file
195
packages/core/src/ui/editor/bubble-menu/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
83
packages/core/src/ui/editor/bubble-menu/link-selector.tsx
Normal file
83
packages/core/src/ui/editor/bubble-menu/link-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
140
packages/core/src/ui/editor/bubble-menu/node-selector.tsx
Normal file
140
packages/core/src/ui/editor/bubble-menu/node-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
163
packages/core/src/ui/editor/bubble-menu/table-selector.tsx
Normal file
163
packages/core/src/ui/editor/bubble-menu/table-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
10
packages/core/src/ui/editor/default-content.tsx
Normal file
10
packages/core/src/ui/editor/default-content.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const defaultEditorContent = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "What's New" }],
|
||||
},
|
||||
],
|
||||
};
|
56
packages/core/src/ui/editor/extensions/custom-keymap.ts
Normal file
56
packages/core/src/ui/editor/extensions/custom-keymap.ts
Normal 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;
|
213
packages/core/src/ui/editor/extensions/drag-and-drop.tsx
Normal file
213
packages/core/src/ui/editor/extensions/drag-and-drop.tsx
Normal 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;
|
51
packages/core/src/ui/editor/extensions/glyphs.tsx
Normal file
51
packages/core/src/ui/editor/extensions/glyphs.tsx
Normal 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;
|
71
packages/core/src/ui/editor/extensions/image-resizer.tsx
Normal file
71
packages/core/src/ui/editor/extensions/image-resizer.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
179
packages/core/src/ui/editor/extensions/index.tsx
Normal file
179
packages/core/src/ui/editor/extensions/index.tsx
Normal 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,
|
||||
];
|
485
packages/core/src/ui/editor/extensions/slash-command.tsx
Normal file
485
packages/core/src/ui/editor/extensions/slash-command.tsx
Normal 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;
|
17
packages/core/src/ui/editor/extensions/updated-image.ts
Normal file
17
packages/core/src/ui/editor/extensions/updated-image.ts
Normal 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;
|
223
packages/core/src/ui/editor/index.tsx
Normal file
223
packages/core/src/ui/editor/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
158
packages/core/src/ui/editor/plugins/upload-images.tsx
Normal file
158
packages/core/src/ui/editor/plugins/upload-images.tsx
Normal 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,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
53
packages/core/src/ui/editor/props.ts
Normal file
53
packages/core/src/ui/editor/props.ts
Normal 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;
|
||||
},
|
||||
};
|
11
packages/core/src/ui/editor/provider.tsx
Normal file
11
packages/core/src/ui/editor/provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
export const NovelContext = createContext<{
|
||||
completionApi: string;
|
||||
plan: string;
|
||||
}>({
|
||||
completionApi: "/api/generate",
|
||||
plan: "5",
|
||||
});
|
164
packages/core/src/ui/editor/styles.css
Normal file
164
packages/core/src/ui/editor/styles.css
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user