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;