import React, { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import { useCompletion } from "../../../lib/ai/react/useCompletion";
import tippy from "tippy.js";
import { Heading1, Heading2, Heading3, List, ListOrdered, Text, TextQuote, Image as ImageIcon, Code } from "lucide-react";
import LoadingCircle from "../../shared/loading-circle";
import { toast } from "react-hot-toast";
// import va from "@vercel/analytics";
import Magic from "../../shared/magic";
import { handleImageUpload } from "../../../lib/utils/editor";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface Command {
editor: Editor;
range: Range;
}
const CommandExtension = 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 }: { query: string }) => {
return [
{
title: "Continue writing",
description: "Use AI to expand your thoughts.",
icon: ,
},
// {
// title: "Send Feedback",
// description: "Let us know how we can improve.",
// icon: ,
// command: ({ editor, range }: Command) => {
// editor.chain().focus().deleteRange(range).run();
// window.open("/feedback", "_blank");
// },
// },
{
title: "Text",
description: "Just start typing with plain text.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "List (•)",
description: "Create a simple bullet list.",
icon:
,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "List (1.)",
description: "Create a list with numbering.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
icon: ,
command: ({ editor, range }: Command) => editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
},
{
title: "Code",
description: "Capture a code snippet.",
icon: ,
command: ({ editor, range }: Command) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
icon: ,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async (event) => {
if (input.files?.length) {
const file = input.files[0];
return handleImageUpload(file, editor.view, event);
}
};
input.click();
},
},
{
title: "Divider",
description: "Insert a divider.",
icon:
,
command: ({ editor, range }: Command) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
return item.title.toLowerCase().includes(query.toLowerCase());
}
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 { complete, isLoading } = useCompletion({
id: "novel",
api: "/api/ai/generate",
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) => {
toast.error(e?.message ?? "Something went wrong.");
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
// va.track("Slash Command Used", {
// command: item.title,
// });
if (item) {
if (item.title === "Continue writing") {
const text = editor.getText();
complete(text, {
body: {
systemContent:
"You are an AI writing assistant that continues existing text based on context from prior text. " +
"Give more weight/priority to the later characters than the beginning ones. Make sure to construct complete sentences.",
},
});
} else {
command(item);
}
}
},
[complete, 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(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
{items.map((item: CommandItemProps, index: number) => {
return (
);
})}
) : 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 = CommandExtension.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export default SlashCommand;