release 0.3.4
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkejs",
|
||||
"version": "0.0.5",
|
||||
"version": "0.1.0",
|
||||
"author": "yesmore",
|
||||
"description": "A Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK.",
|
||||
"keywords": [
|
||||
@@ -35,10 +35,15 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "^2.7.1",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-character-count": "^2.1.12",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.12",
|
||||
"@tiptap/extension-collaboration": "^2.1.12",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||
"@tiptap/extension-color": "^2.1.7",
|
||||
"@tiptap/extension-font-family": "^2.1.12",
|
||||
"@tiptap/extension-highlight": "^2.1.7",
|
||||
"@tiptap/extension-horizontal-rule": "^2.1.7",
|
||||
"@tiptap/extension-image": "^2.1.7",
|
||||
@@ -50,6 +55,7 @@
|
||||
"@tiptap/extension-table-row": "^2.1.12",
|
||||
"@tiptap/extension-task-item": "^2.1.7",
|
||||
"@tiptap/extension-task-list": "^2.1.7",
|
||||
"@tiptap/extension-text-align": "^2.1.12",
|
||||
"@tiptap/extension-text-style": "^2.1.7",
|
||||
"@tiptap/extension-typography": "^2.1.11",
|
||||
"@tiptap/extension-underline": "^2.1.7",
|
||||
@@ -82,7 +88,9 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"typescript": "4.9.5",
|
||||
"use-debounce": "^9.0.4"
|
||||
"use-debounce": "^9.0.4",
|
||||
"y-prosemirror": "^1.2.1",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.5",
|
||||
|
237
packages/core/src/ui/editor/bot/chat-bot.tsx
Normal file
237
packages/core/src/ui/editor/bot/chat-bot.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NovelContext } from "../provider";
|
||||
import { useChat } from "ai/react";
|
||||
import {
|
||||
Baby,
|
||||
Bot,
|
||||
Clipboard,
|
||||
Minus,
|
||||
PauseCircle,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Trash,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Magic1 from "@/ui/icons/magic-1";
|
||||
import { motion } from "framer-motion";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ChatBot({ editor }: { editor: Editor }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
input,
|
||||
isLoading,
|
||||
reload,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
} = useChat({
|
||||
id: "ai-bot",
|
||||
api: `${completionApi}/bot`,
|
||||
body: { plan, system: editor.getText() },
|
||||
initialMessages: [
|
||||
{
|
||||
id: "start",
|
||||
role: "system",
|
||||
content: "Here, ask me about your note :)",
|
||||
},
|
||||
],
|
||||
onError: (err) => {
|
||||
if (
|
||||
err.message !== "Failed to fetch" &&
|
||||
err.message !== "network error"
|
||||
) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
|
||||
const handleChat = () => {
|
||||
if (isLoading) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (!inputRef.current?.value) return;
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOpen = () => {
|
||||
editor.chain().blur();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
`${isOpen ? "novel-bottom-3" : "novel-bottom-16"}` +
|
||||
" novel-fixed novel-z-[1009] novel-right-3 novel-animate-in novel-fade-in novel-slide-in-from-bottom-1"
|
||||
}>
|
||||
<motion.div
|
||||
className="novel-rounded-full"
|
||||
initial={{ borderRadius: "50%", x: 0 }}
|
||||
animate={{ borderRadius: isOpen ? "0%" : "50%", x: isOpen ? 0 : 35 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
{isOpen ? (
|
||||
<div className="chat novel-border novel-relative novel-w-[350px] novel-border-slate-100 novel-bg-white novel-shadow-lg novel-rounded-lg">
|
||||
<div className="msgs novel-p-2">
|
||||
<div className="flex novel-mb-2 novel-pb-2 novel-border-slate-100 novel-border-b novel-justify-between novel-items-center">
|
||||
<Magic1 className="novel-h-6 novel-w-6 translate-y-1 novel-text-cyan-400" />
|
||||
<span className="novel-font-semibold">Chat with note</span>
|
||||
<div className="novel-flex novel-items-center novel-gap-3">
|
||||
<Trash
|
||||
onClick={() => setMessages([])}
|
||||
className="novel-float-right novel-rounded-md novel-cursor-pointer novel-w-4 novel-h-4 hover:novel-text-red-300 novel-text-slate-600"
|
||||
/>
|
||||
<Minus
|
||||
onClick={toggleOpen}
|
||||
className="novel-float-right novel-rounded-md novel-cursor-pointer novel-w-6 novel-h-6 novel-px-1 hover:novel-bg-slate-200 novel-text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="novel-h-80 novel-pb-10 novel-overflow-auto">
|
||||
{messages.map((m, index) =>
|
||||
m.role === "user" ? (
|
||||
<motion.div
|
||||
className="novel-text-sm novel-group novel-mb-3 novel-gap-2 novel-w-full novel-flex novel-items-start novel-justify-end"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
key={index}>
|
||||
<div className="novel-hidden group-hover:novel-block">
|
||||
<Clipboard
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(m.content)
|
||||
}
|
||||
className="novel-w-3 novel-mb-1 novel-text-slate-400 active:novel-text-green-500 novel-h-3 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
<Trash2
|
||||
onClick={() => {
|
||||
const new_list = messages.filter(
|
||||
(i) => i.content !== m.content
|
||||
);
|
||||
setMessages(new_list);
|
||||
}}
|
||||
className="novel-w-3 novel-text-slate-400 active:novel-text-red-500 novel-h-3 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</div>
|
||||
<ReactMarkdown className="novel-py-1 novel-text-slate-700 novel-max-w-[260px] novel-px-2 novel-bg-slate-200 novel-rounded-md">
|
||||
{m.content}
|
||||
</ReactMarkdown>
|
||||
<span className="novel-py-1 novel-px-2 novel-font-semibold novel-bg-slate-100 novel-rounded-full">
|
||||
<Baby className="novel-w-5 novel-h-5 novel-text-blue-400" />
|
||||
</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="novel-text-sm novel-group novel-mb-3 novel-gap-2 novel-w-full novel-flex novel-items-start novel-justify-start"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
key={index}>
|
||||
<span className="novel-py-1 novel-px-2 novel-font-semibold novel-bg-slate-100 novel-rounded-full">
|
||||
<Bot className="novel-w-5 novel-h-5 novel-text-cyan-400" />
|
||||
</span>
|
||||
<ReactMarkdown className="novel-py-1 novel-text-slate-700 novel-max-w-[260px] novel-px-2 novel-bg-slate-200 novel-rounded-md">
|
||||
{m.content}
|
||||
</ReactMarkdown>
|
||||
<div className="novel-hidden novel-h-full novel-mt-auto group-hover:novel-block">
|
||||
<Clipboard
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(m.content)
|
||||
}
|
||||
className="novel-w-3 novel-mb-1 novel-text-slate-400 active:novel-text-green-500 novel-h-3 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
<Trash2
|
||||
onClick={() => {
|
||||
const new_list = messages.filter(
|
||||
(i) => i.content !== m.content
|
||||
);
|
||||
setMessages(new_list);
|
||||
}}
|
||||
className="novel-w-3 novel-text-slate-400 active:novel-text-red-500 novel-h-3 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="novel-flex novel-p-2 novel-items-end novel-justify-center">
|
||||
<Bot
|
||||
onClick={toggleOpen}
|
||||
className="novel-h-5 novel-cursor-pointer novel-mr-2 novel-mb-2.5 novel-w-5 translate-y-1 novel-text-cyan-500"
|
||||
/>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
maxLength={300}
|
||||
style={{ maxHeight: "150px", minHeight: "40px" }}
|
||||
rows={1}
|
||||
className="novel-flex-grow novel-text-sm novel-border-l novel-border-y novel-border-gray-100 novel-shadow-inner novel-rounded-l-lg novel-px-4 novel-py-2 focus:novel-outline-none"
|
||||
placeholder="Ask note..."
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<button
|
||||
onClick={handleChat}
|
||||
type="submit"
|
||||
className="novel-px-3 novel-py-3 novel-bg-slate-100 novel-text-white novel-rounded-r-lg hover:novel-bg-slate-300">
|
||||
{!isLoading ? (
|
||||
<Send className="novel-h-4 novel-w-4 novel-text-blue-400" />
|
||||
) : (
|
||||
<PauseCircle className="novel-h-4 novel-animate-pulse novel-w-4 novel-text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
onClick={stop}
|
||||
className="novel-absolute novel-animate-pulse novel-cursor-pointer novel-bottom-16 novel-z-10 novel-left-1/2 novel-transform novel--translate-x-1/2 novel-px-4 novel-py-1 novel-flex novel-justify-center novel-items-center novel-gap-1 novel-border novel-rounded-md novel-border-slate-200 novel-bg-slate-50 hover:novel-bg-slate-300">
|
||||
<PauseCircle className="novel-w-4 novel-h-4 novel-text-slate-500" />
|
||||
<span className="novel-text-sm novel-text-slate-500">
|
||||
Abort
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{messages.length >= 2 && !isLoading && (
|
||||
<div
|
||||
onClick={() => reload()}
|
||||
className="novel-absolute novel-cursor-pointer novel-bottom-16 novel-z-10 novel-left-1/2 novel-transform novel--translate-x-1/2 novel-px-2 novel-py-1 novel-flex novel-justify-center novel-items-center novel-gap-1 novel-border novel-rounded-md novel-border-slate-200 novel-bg-slate-50 hover:novel-bg-slate-300">
|
||||
<RefreshCcw className="novel-w-4 novel-h-4 novel-text-slate-500" />
|
||||
<span className="novel-text-sm novel-text-slate-500">
|
||||
Regenerate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="novel-p-3.5 hover:-novel-translate-x-6 novel-border novel-border-slate-100 novel-transition-all novel-bg-white novel-shadow novel-shadow-purple-100 novel-opacity-75 hover:novel-opacity-100 novel-rounded-full"
|
||||
onClick={toggleOpen}>
|
||||
<Bot className="novel-h-5 novel-w-5 translate-y-1 novel-text-cyan-500" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -7,6 +7,7 @@ import { useContext, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import va from "@vercel/analytics";
|
||||
import { NovelContext } from "../../../provider";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -18,14 +19,11 @@ const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { completion, setCompletion, isLoading, stop } = useCompletion({
|
||||
id: "novel-edit",
|
||||
id: "ai-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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,10 +47,10 @@ const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
};
|
||||
|
||||
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-fixed novel-z-[10000] 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" />
|
||||
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-cyan-500" />
|
||||
{isLoading && (
|
||||
<div className="novel-mr-auto novel-flex novel-items-center">
|
||||
<LoadingDots color="#9e9e9e" />
|
||||
@@ -82,8 +80,11 @@ const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{completion.length > 0 && (
|
||||
<div className="novel-text-sm mt-2">{completion}</div>
|
||||
<ReactMarkdown className="novel-text-sm mt-2">
|
||||
{completion}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -2,6 +2,7 @@ import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
Beef,
|
||||
Book,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Heading1,
|
||||
@@ -11,13 +12,17 @@ import {
|
||||
PartyPopper,
|
||||
PauseCircle,
|
||||
Scissors,
|
||||
Send,
|
||||
SprayCan,
|
||||
Trash,
|
||||
Wand,
|
||||
} from "lucide-react";
|
||||
import { FC, useContext, useEffect } from "react";
|
||||
import { FC, useContext, useEffect, useRef } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import Magic from "@/ui/icons/magic";
|
||||
import { useCompletion } from "ai/react";
|
||||
import { NovelContext } from "../../../provider";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AISelectorProps {
|
||||
editor: Editor;
|
||||
@@ -78,14 +83,10 @@ export const AISelector: FC<AISelectorProps> = ({
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
|
||||
@@ -102,62 +103,90 @@ export const AISelector: FC<AISelectorProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { complete, isLoading, stop } = useCompletion({
|
||||
id: "novel-edit",
|
||||
id: "ai-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">
|
||||
<div className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-text-sm novel-font-medium novel-text-cyan-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"
|
||||
className="novel-p-2 novel-flex novel-h-full novel-items-center novel-gap-2"
|
||||
onClick={() => {
|
||||
if (isLoading) {
|
||||
stop();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
editor.chain().blur().run();
|
||||
}}>
|
||||
<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" />
|
||||
) : (
|
||||
<SprayCan className="novel-h-5 novel-w-5" />
|
||||
{isLoading ? (
|
||||
<PauseCircle
|
||||
onClick={stop}
|
||||
className="novel-h-4 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown className="novel-h-4 novel-w-4" />
|
||||
)}
|
||||
</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>
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget[0] as HTMLInputElement;
|
||||
if (!input.value) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, " ");
|
||||
complete(`${input.value}:\n ${text}`);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-flex novel-w-full 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="Make this para funnier..."
|
||||
className="novel-flex-1 novel-bg-white novel-p-1 novel-text-sm novel-outline-none"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
<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">
|
||||
<Send className="novel-h-4 novel-w-4 novel-text-cyan-500" />
|
||||
</button>
|
||||
</form>
|
||||
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-[46.5px] 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-cyan-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>
|
||||
);
|
||||
|
@@ -5,8 +5,8 @@ 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";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -18,14 +18,11 @@ const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { completion, setCompletion, isLoading, stop } = useCompletion({
|
||||
id: "novel-translate",
|
||||
id: "ai-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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,10 +46,10 @@ const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
};
|
||||
|
||||
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-fixed novel-z-[10001] 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" />
|
||||
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-cyan-500" />
|
||||
{isLoading && (
|
||||
<div className="novel-mr-auto novel-flex novel-items-center">
|
||||
<LoadingDots color="#9e9e9e" />
|
||||
@@ -83,7 +80,9 @@ const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
{completion.length > 0 && (
|
||||
<div className="novel-text-sm mt-2">{completion}</div>
|
||||
<ReactMarkdown className="novel-text-sm mt-2">
|
||||
{completion}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -78,7 +78,7 @@ export const TranslateSelector: FC<TranslateSelectorProps> = ({
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { complete, isLoading, stop } = useCompletion({
|
||||
id: "novel-translate",
|
||||
id: "ai-translate",
|
||||
api: `${completionApi}/translate`,
|
||||
body: { plan },
|
||||
});
|
||||
|
@@ -8,6 +8,11 @@ export interface BubbleColorMenuItem {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BubbleFontMenuItem {
|
||||
name: string;
|
||||
font: string;
|
||||
}
|
||||
|
||||
interface ColorSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
@@ -92,6 +97,33 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const TEXT_FONT: BubbleFontMenuItem[] = [
|
||||
{
|
||||
name: "Default",
|
||||
font: "",
|
||||
},
|
||||
{
|
||||
name: "Inter",
|
||||
font: "Inter",
|
||||
},
|
||||
{
|
||||
name: "Comic Sans",
|
||||
font: "Comic Sans MS, Comic Sans",
|
||||
},
|
||||
{
|
||||
name: "monospace",
|
||||
font: "monospace",
|
||||
},
|
||||
{
|
||||
name: "serif",
|
||||
font: "serif",
|
||||
},
|
||||
{
|
||||
name: "cursive",
|
||||
font: "cursive",
|
||||
},
|
||||
];
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
editor,
|
||||
isOpen,
|
||||
@@ -105,6 +137,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
editor.isActive("highlight", { color })
|
||||
);
|
||||
|
||||
const activeFontItem = TEXT_FONT.find(({ font }) =>
|
||||
editor.isActive("textStyle", { font })
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Root open={isOpen}>
|
||||
<div className="novel-relative novel-h-full">
|
||||
@@ -116,11 +152,12 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
style={{
|
||||
color: activeColorItem?.color,
|
||||
backgroundColor: activeHighlightItem?.color,
|
||||
fontFamily: activeFontItem?.font,
|
||||
}}>
|
||||
A
|
||||
</span>
|
||||
|
||||
{/* <ChevronDown className="novel-h-4 novel-w-4" /> */}
|
||||
<ChevronDown className="novel-h-4 novel-w-4" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
@@ -161,7 +198,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<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}
|
||||
@@ -185,6 +221,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="novel-mb-1 novel-mt-2 novel-px-2 novel-text-sm novel-text-stone-500">
|
||||
Font
|
||||
</div>
|
||||
{TEXT_FONT.map(({ name, font }, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (name !== "Default") {
|
||||
editor.commands.setFontFamily(font);
|
||||
} else {
|
||||
editor.commands.unsetFontFamily();
|
||||
}
|
||||
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={{ fontFamily: font }}>
|
||||
A
|
||||
</div>
|
||||
<span style={{ fontFamily: font }}>{name}</span>
|
||||
</div>
|
||||
{editor.isActive("textStyle", { font }) && (
|
||||
<Check className="novel-h-4 novel-w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</div>
|
||||
</Popover.Root>
|
||||
|
@@ -5,12 +5,14 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
TextQuote,
|
||||
ListOrdered,
|
||||
TextIcon,
|
||||
Code,
|
||||
CheckSquare,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignLeft,
|
||||
} from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
@@ -93,6 +95,24 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => editor.isActive("codeBlock"),
|
||||
},
|
||||
{
|
||||
name: "Center align",
|
||||
icon: AlignCenter,
|
||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||
isActive: () => false,
|
||||
},
|
||||
{
|
||||
name: "Right align",
|
||||
icon: AlignRight,
|
||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||
isActive: () => false,
|
||||
},
|
||||
{
|
||||
name: "Left align",
|
||||
icon: AlignLeft,
|
||||
command: () => editor.commands.setTextAlign("left"),
|
||||
isActive: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
|
113
packages/core/src/ui/editor/extensions/collaboration.tsx
Normal file
113
packages/core/src/ui/editor/extensions/collaboration.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
// import * as Y from "yjs";
|
||||
// import { TiptapCollabProvider } from "@hocuspocus/provider";
|
||||
import { useMemo } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
export interface User {
|
||||
clientId?: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function useCollaborationExt(
|
||||
active: boolean,
|
||||
id: string,
|
||||
user: User
|
||||
): any {
|
||||
const collaborationData = useMemo(() => {
|
||||
if (!active) return {};
|
||||
|
||||
const name = `inke-${id}`;
|
||||
const provider = new HocuspocusProvider({
|
||||
// ws://107.172.87.158:1234 wss://ws.taoist.fun ws://127.0.0.1:1234
|
||||
url: "wss://ws.taoist.fun",
|
||||
name,
|
||||
});
|
||||
|
||||
// const ydoc = new Y.Doc();
|
||||
// const wsprovider = new TiptapCollabProvider({
|
||||
// appId: "7j9y6m10", // 89jn14k7
|
||||
// name,
|
||||
// document: ydoc,
|
||||
// });
|
||||
|
||||
return {
|
||||
collaborates: [
|
||||
Collaboration.configure({ document: provider.document }),
|
||||
CollaborationCursor.configure({
|
||||
provider: provider,
|
||||
user,
|
||||
}),
|
||||
],
|
||||
provider: provider, // provider.document / wsprovider
|
||||
};
|
||||
}, [active, id]);
|
||||
|
||||
return collaborationData;
|
||||
}
|
||||
|
||||
export function CollaborationInfo({
|
||||
status,
|
||||
editor,
|
||||
}: {
|
||||
status: string;
|
||||
editor: Editor;
|
||||
}) {
|
||||
return (
|
||||
<div className="novel-fixed novel-z-[9999] novel-bottom-3 novel-right-3">
|
||||
{status === "connected" ? (
|
||||
<div className="novel-flex novel-group novel-font-semibold novel-gap-1 novel-items-center novel-justify-center">
|
||||
<Users className="novel-h-4 novel-text-cyan-500 novel-w-4" />
|
||||
<span className="novel-text-xs novel-text-slate-500">
|
||||
{editor.storage?.collaborationCursor?.users?.length}
|
||||
</span>
|
||||
<div className="novel-hidden novel-z-[10000] novel-bg-slate-50/90 novel-max-h-64 novel-overflow-y-auto novel-p-2 novel-w-44 novel-border-slate-100 novel-rounded-md novel-shadow-md novel-absolute novel-bottom-0 novel-right-0 group-hover:novel-block">
|
||||
<p className="novel-gap-2 novel-mb-1 novel-items-center novel-flex novel-text-xs novel-text-slate-600 novel-pb-1 novel-border-b novel-border-slate-100">
|
||||
<Users className="novel-h-4 novel-text-cyan-500 novel-w-4" />
|
||||
{editor.storage.collaborationCursor.users.length} user
|
||||
{editor.storage.collaborationCursor.users.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
online
|
||||
</p>
|
||||
|
||||
{editor.storage?.collaborationCursor?.users?.map((i: User) => (
|
||||
<div
|
||||
key={i.clientId}
|
||||
className="novel-truncate novel-flex novel-items-center novel-gap-2 novel-cursor-pointer hover:novel-opacity-80 novel-font-mono novel-pt-1 novel-text-xs novel-text-slate-500">
|
||||
<i
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: i.color,
|
||||
display: "block",
|
||||
transition: "all 0.5s",
|
||||
}}
|
||||
/>
|
||||
<span>{i.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="novel-text-sm novel-animate-pulse novel-text-slate-500">
|
||||
connecting...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateRandomColorCode(): string {
|
||||
const letters = "0123456789ABCDEF";
|
||||
let colorCode = "#";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
colorCode += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return colorCode;
|
||||
}
|
56
packages/core/src/ui/editor/extensions/color-highlighter.ts
Normal file
56
packages/core/src/ui/editor/extensions/color-highlighter.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
export const ColorHighlighter = Extension.create({
|
||||
name: "colorHighlighter",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return findColors(doc);
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged
|
||||
? findColors(transaction.doc)
|
||||
: oldState;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function findColors(doc: Node): DecorationSet {
|
||||
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, position) => {
|
||||
if (!node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
|
||||
const color = match[0];
|
||||
const index = match.index || 0;
|
||||
const from = position + index;
|
||||
const to = from + color.length;
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: "color",
|
||||
style: `--color: ${color}`,
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
@@ -5,6 +5,9 @@ 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 TextAlign from "@tiptap/extension-text-align";
|
||||
import FontFamily from "@tiptap/extension-font-family";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
@@ -14,6 +17,7 @@ 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";
|
||||
@@ -21,15 +25,18 @@ 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";
|
||||
import { ColorHighlighter } from "./color-highlighter";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register({ markdown });
|
||||
@@ -38,10 +45,14 @@ lowlight.register({ css });
|
||||
lowlight.register({ js });
|
||||
lowlight.register({ ts });
|
||||
|
||||
export const defaultExtensions = [
|
||||
export const defaultExtensions = (collaboration: boolean) => [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
// HTMLAttributes: {
|
||||
// href: "ls",
|
||||
// class: "head- novel-cursor-pointer hover:after:novel-content-['_#']",
|
||||
// },
|
||||
},
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
@@ -77,6 +88,7 @@ export const defaultExtensions = [
|
||||
width: 4,
|
||||
},
|
||||
gapcursor: false,
|
||||
history: !collaboration as any,
|
||||
}),
|
||||
// patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740
|
||||
HorizontalRule.extend({
|
||||
@@ -134,10 +146,17 @@ export const defaultExtensions = [
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
defaultAlignment: "left",
|
||||
types: ["heading", "paragraph"],
|
||||
alignments: ["left", "center", "right"],
|
||||
}),
|
||||
SlashCommand,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
Color,
|
||||
ColorHighlighter,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
@@ -157,6 +176,7 @@ export const defaultExtensions = [
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CharacterCount.configure({}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
|
@@ -337,9 +337,10 @@ const CommandList = ({
|
||||
});
|
||||
},
|
||||
onError: (e) => {
|
||||
if (e.message !== "Failed to fetch") {
|
||||
toast.error(e.message);
|
||||
}
|
||||
toast.error(e.message);
|
||||
// if (e.message !== "Failed to fetch") {
|
||||
// toast.error(e.message);
|
||||
// }
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -20,6 +20,14 @@ 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";
|
||||
import { ChatBot } from "./bot/chat-bot";
|
||||
import {
|
||||
CollaborationInfo,
|
||||
User,
|
||||
generateRandomColorCode,
|
||||
useCollaborationExt,
|
||||
} from "./extensions/collaboration";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
export default function Editor({
|
||||
completionApi = "/api/generate",
|
||||
@@ -34,6 +42,10 @@ export default function Editor({
|
||||
disableLocalStorage = false,
|
||||
editable = true,
|
||||
plan = "5",
|
||||
bot = false,
|
||||
collaboration = false,
|
||||
id = "",
|
||||
userName = "unkown",
|
||||
}: {
|
||||
/**
|
||||
* The API route to use for the OpenAI completion API.
|
||||
@@ -97,7 +109,29 @@ export default function Editor({
|
||||
* Defaults to true.
|
||||
*/
|
||||
editable?: boolean;
|
||||
/**
|
||||
* User plan.
|
||||
* Defaults to "5".
|
||||
*/
|
||||
plan?: string;
|
||||
/**
|
||||
* Bot: chat with note.
|
||||
* Defaults to false.
|
||||
*/
|
||||
bot?: boolean;
|
||||
/**
|
||||
* Id: collaboration room id.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Collaboration: enable collaboration space.
|
||||
* Defaults to false.
|
||||
*/
|
||||
collaboration?: boolean;
|
||||
/**
|
||||
* userName: collaboration userName.
|
||||
*/
|
||||
userName?: string;
|
||||
}) {
|
||||
const [content, setContent] = useLocalStorage(storageKey, defaultValue);
|
||||
|
||||
@@ -109,6 +143,7 @@ export default function Editor({
|
||||
const json = editor.getJSON();
|
||||
const text = editor.getText();
|
||||
const markdown = editor.storage.markdown.getMarkdown();
|
||||
// const count = editor.storage.characterCount.characters();
|
||||
|
||||
onDebouncedUpdate(json, text, markdown, editor);
|
||||
|
||||
@@ -117,8 +152,24 @@ export default function Editor({
|
||||
}
|
||||
}, debounceDuration);
|
||||
|
||||
const [status, setStatus] = useState("connecting");
|
||||
const user = {
|
||||
name: userName,
|
||||
color: generateRandomColorCode(),
|
||||
};
|
||||
|
||||
const { collaborates, provider } = useCollaborationExt(
|
||||
collaboration,
|
||||
id,
|
||||
user
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [...defaultExtensions, ...extensions],
|
||||
extensions: [
|
||||
...defaultExtensions(collaboration),
|
||||
...extensions,
|
||||
...(collaboration && collaborates ? collaborates : []),
|
||||
],
|
||||
editorProps: {
|
||||
...defaultEditorProps,
|
||||
...editorProps,
|
||||
@@ -149,6 +200,16 @@ export default function Editor({
|
||||
autofocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collaboration) {
|
||||
// Update status changes
|
||||
provider.on("status", (event: any) => {
|
||||
setStatus(event.status);
|
||||
editor?.chain().focus().updateUser(user).run();
|
||||
});
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const { complete, completion, isLoading, stop } = useCompletion({
|
||||
id: "ai-continue",
|
||||
api: `${completionApi}/continue`,
|
||||
@@ -161,9 +222,6 @@ export default function Editor({
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
if (err.message === "You have reached your request limit for the day.") {
|
||||
va.track("Rate Limit Reached");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,6 +268,10 @@ export default function Editor({
|
||||
<AITranslateBubble editor={editor} />
|
||||
</>
|
||||
)}
|
||||
{editor && collaboration && (
|
||||
<CollaborationInfo status={status} editor={editor} />
|
||||
)}
|
||||
|
||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||
<EditorContent editor={editor} />
|
||||
{isLoadingOutside && isLoading && (
|
||||
@@ -217,6 +279,7 @@ export default function Editor({
|
||||
<AIGeneratingLoading stop={stop} />
|
||||
</div>
|
||||
)}
|
||||
{bot && editor && <ChatBot editor={editor} />}
|
||||
</div>
|
||||
</NovelContext.Provider>
|
||||
);
|
||||
|
@@ -162,3 +162,74 @@
|
||||
transition: outline 0.15s;
|
||||
outline: 3px solid #3c69ff;
|
||||
}
|
||||
|
||||
/* cur */
|
||||
|
||||
.collaboration-cursor__caret {
|
||||
border-left: 1px solid #0d0d0d;
|
||||
border-right: 1px solid #0d0d0d;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Render the username above the caret */
|
||||
.collaboration-cursor__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #f8f8f8;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
left: -1px;
|
||||
line-height: normal;
|
||||
padding: 0.1rem 0.3rem;
|
||||
position: absolute;
|
||||
top: -1.4em;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.status {
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.status--connected::before {
|
||||
background: #b9f18d;
|
||||
}
|
||||
|
||||
.status--connecting::before {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
background: rgba(#0d0d0d, 0.5);
|
||||
border-radius: 50%;
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
height: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.tiptap .color {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap .color::before {
|
||||
background-color: var(--color);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 2px;
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
margin-bottom: 0.15em;
|
||||
margin-right: 0.1em;
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
}
|
||||
|
18
packages/core/src/ui/icons/magic-1.tsx
Normal file
18
packages/core/src/ui/icons/magic-1.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export default function Magic1({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
className={className}>
|
||||
<path
|
||||
d="M9.2467 3C9.65074 6.17905 12.5275 9.00324 15.6934 9.5C12.5275 9.99676 9.65074 12.8209 9.24669 16C8.84265 12.8209 6.16589 9.99676 3 9.5C6.16589 9.00324 8.84265 6.19877 9.2467 3.01971M17.3 20L17.2329 19.5924C17.0448 18.4504 16.1496 17.5552 15.0076 17.3671L14.6 17.3L15.0076 17.2329C16.1496 17.0448 17.0448 16.1496 17.2329 15.0076L17.3 14.6L17.3671 15.0076C17.5552 16.1496 18.4504 17.0448 19.5924 17.2329L20 17.3L19.5924 17.3671C18.4504 17.5552 17.5552 18.4504 17.3671 19.5924L17.3 20Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
@@ -11,8 +11,7 @@ export default function Magic({ className }: { className: string }) {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
className={className}
|
||||
>
|
||||
className={className}>
|
||||
<path
|
||||
d="M237.092 62.3004L266.754 71.4198C267.156 71.5285 267.51 71.765 267.765 72.0934C268.02 72.4218 268.161 72.8243 268.166 73.2399C268.172 73.6555 268.042 74.0616 267.796 74.3967C267.55 74.7318 267.201 74.9777 266.803 75.097L237.141 84.3145C236.84 84.4058 236.566 84.5699 236.344 84.7922C236.121 85.0146 235.957 85.2883 235.866 85.5893L226.747 115.252C226.638 115.653 226.401 116.008 226.073 116.263C225.745 116.517 225.342 116.658 224.926 116.664C224.511 116.669 224.105 116.539 223.77 116.293C223.435 116.047 223.189 115.699 223.069 115.301L213.852 85.6383C213.761 85.3374 213.597 85.0636 213.374 84.8412C213.152 84.6189 212.878 84.4548 212.577 84.3635L182.914 75.2441C182.513 75.1354 182.158 74.8989 181.904 74.5705C181.649 74.2421 181.508 73.8396 181.503 73.424C181.497 73.0084 181.627 72.6023 181.873 72.2672C182.119 71.9321 182.467 71.6863 182.865 71.5669L212.528 62.3494C212.829 62.2582 213.103 62.0941 213.325 61.8717C213.547 61.6494 213.712 61.3756 213.803 61.0747L222.922 31.4121C223.031 31.0109 223.267 30.656 223.596 30.4013C223.924 30.1465 224.327 30.0057 224.742 30.0002C225.158 29.9946 225.564 30.1247 225.899 30.3706C226.234 30.6165 226.48 30.9649 226.599 31.363L235.817 61.0257C235.908 61.3266 236.072 61.6003 236.295 61.8227C236.517 62.0451 236.791 62.2091 237.092 62.3004Z"
|
||||
fill="currentColor"
|
||||
|
Reference in New Issue
Block a user