aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components/new/text-editor/slash-command.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/new/text-editor/slash-command.tsx')
-rw-r--r--apps/web/components/new/text-editor/slash-command.tsx308
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,
+ }),
+ ]
+ },
+ })
+}