diff options
Diffstat (limited to 'apps/web/components/new/text-editor/slash-command.tsx')
| -rw-r--r-- | apps/web/components/new/text-editor/slash-command.tsx | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/apps/web/components/new/text-editor/slash-command.tsx b/apps/web/components/new/text-editor/slash-command.tsx new file mode 100644 index 00000000..31991093 --- /dev/null +++ b/apps/web/components/new/text-editor/slash-command.tsx @@ -0,0 +1,308 @@ +"use client" + +import { Extension, type Editor, type Range } from "@tiptap/core" +import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion" +import { useEffect, useLayoutEffect, useState, useRef } from "react" +import { createPortal } from "react-dom" +import { createRoot, type Root } from "react-dom/client" +import { + useFloating, + offset, + flip, + shift, + autoUpdate, +} from "@floating-ui/react" +import { cn } from "@lib/utils" + +export interface SuggestionItem { + title: string + description: string + searchTerms?: string[] + icon: React.ReactNode + command: (props: { editor: Editor; range: Range }) => void +} + +interface CommandListProps { + items: SuggestionItem[] + command: (item: SuggestionItem) => void + selectedIndex: number +} + +function CommandList({ items, command, selectedIndex }: CommandListProps) { + const containerRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + const selectedElement = containerRef.current?.querySelector( + `[data-index="${selectedIndex}"]`, + ) + selectedElement?.scrollIntoView({ block: "nearest" }) + }, [selectedIndex]) + + if (items.length === 0) { + return ( + <div className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]"> + <div className="px-2 text-muted-foreground">No results</div> + </div> + ) + } + + return ( + <div + ref={containerRef} + className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]" + > + {items.map((item, index) => ( + <button + type="button" + key={item.title} + data-index={index} + onClick={() => command(item)} + className={cn( + "flex w-full items-center gap-2 rounded-[4px] px-3 py-2 text-left hover:bg-[#2e353d]", + index === selectedIndex && "bg-[#2e353d]", + )} + > + <div className="flex size-[20px] shrink-0 items-center justify-center text-[#fafafa]"> + {item.icon} + </div> + <p className="font-medium text-[16px] leading-[1.35] tracking-[-0.16px] text-[#fafafa]"> + {item.title} + </p> + </button> + ))} + </div> + ) +} + +interface CommandMenuProps { + items: SuggestionItem[] + command: (item: SuggestionItem) => void + clientRect: (() => DOMRect | null) | null + selectedIndex: number +} + +function CommandMenu({ + items, + command, + clientRect, + selectedIndex, +}: CommandMenuProps) { + const [mounted, setMounted] = useState(false) + + const { refs, floatingStyles } = useFloating({ + placement: "bottom-start", + middleware: [offset(8), flip(), shift()], + whileElementsMounted: autoUpdate, + }) + + useLayoutEffect(() => { + setMounted(true) + }, []) + + useEffect(() => { + const rect = clientRect?.() + if (rect) { + refs.setReference({ + getBoundingClientRect: () => rect, + }) + } + }, [clientRect, refs]) + + if (!mounted) return null + + return createPortal( + <div ref={refs.setFloating} style={floatingStyles} className="z-50"> + <CommandList + items={items} + command={command} + selectedIndex={selectedIndex} + /> + </div>, + document.body, + ) +} + +export function createSlashCommand(items: SuggestionItem[]) { + let component: { + updateProps: (props: CommandMenuProps) => void + destroy: () => void + element: HTMLElement + } | null = null + let root: Root | null = null + let selectedIndex = 0 + let currentItems: SuggestionItem[] = [] + + const renderMenu = (props: { + items: SuggestionItem[] + command: (item: SuggestionItem) => void + clientRect: (() => DOMRect | null) | null + }) => { + root?.render( + <CommandMenu + items={props.items} + command={props.command} + clientRect={props.clientRect} + selectedIndex={selectedIndex} + />, + ) + } + + const suggestion: Omit<SuggestionOptions<SuggestionItem>, "editor"> = { + char: "/", + items: ({ query }) => { + return items.filter( + (item) => + item.title.toLowerCase().includes(query.toLowerCase()) || + item.searchTerms?.some((term) => + term.toLowerCase().includes(query.toLowerCase()), + ), + ) + }, + command: ({ editor, range, props }) => { + props.command({ editor, range }) + }, + render: () => { + let currentCommand: ((item: SuggestionItem) => void) | null = null + let currentClientRect: (() => DOMRect | null) | null = null + + return { + onStart: (props) => { + selectedIndex = 0 + currentItems = props.items as SuggestionItem[] + currentCommand = props.command as ( + item: SuggestionItem, + ) => void + currentClientRect = props.clientRect ?? null + + const element = document.createElement("div") + document.body.appendChild(element) + + root = createRoot(element) + if (currentCommand) { + renderMenu({ + items: currentItems, + command: currentCommand, + clientRect: currentClientRect, + }) + } + + component = { + element, + updateProps: (newProps: CommandMenuProps) => { + root?.render( + <CommandMenu + items={newProps.items} + command={newProps.command} + clientRect={newProps.clientRect} + selectedIndex={newProps.selectedIndex} + />, + ) + }, + destroy: () => { + root?.unmount() + element.remove() + root = null + }, + } + }, + + onUpdate: (props) => { + currentItems = props.items as SuggestionItem[] + currentCommand = props.command as ( + item: SuggestionItem, + ) => void + currentClientRect = props.clientRect ?? null + + if (selectedIndex >= currentItems.length) { + selectedIndex = Math.max(0, currentItems.length - 1) + } + + if (currentCommand) { + component?.updateProps({ + items: currentItems, + command: currentCommand, + clientRect: currentClientRect, + selectedIndex, + }) + } + }, + + onKeyDown: (props) => { + const { event } = props + + if (event.key === "Escape") { + component?.destroy() + component = null + return true + } + + if (event.key === "ArrowUp") { + selectedIndex = + selectedIndex <= 0 + ? currentItems.length - 1 + : selectedIndex - 1 + if (currentCommand) { + component?.updateProps({ + items: currentItems, + command: currentCommand, + clientRect: currentClientRect, + selectedIndex, + }) + } + return true + } + + if (event.key === "ArrowDown") { + selectedIndex = + selectedIndex >= currentItems.length - 1 + ? 0 + : selectedIndex + 1 + if (currentCommand) { + component?.updateProps({ + items: currentItems, + command: currentCommand, + clientRect: currentClientRect, + selectedIndex, + }) + } + return true + } + + if (event.key === "Enter") { + const item = currentItems[selectedIndex] + if (item && currentCommand) { + currentCommand(item) + } + return true + } + + return false + }, + + onExit: () => { + component?.destroy() + component = null + }, + } + }, + } + + return Extension.create({ + name: "slashCommand", + + addOptions() { + return { + suggestion, + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, + }) +} |