supermemory/apps/web/components/text-editor/slash-command.tsx
2026-02-16 14:30:29 -07:00

300 lines
7 KiB
TypeScript

"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,
}),
]
},
})
}