mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-17 12:20:04 +00:00
300 lines
7 KiB
TypeScript
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,
|
|
}),
|
|
]
|
|
},
|
|
})
|
|
}
|