SPB Git
3.6 KB · 51 lines tsx
1"use client";2import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react";3import { ReactRenderer } from "@tiptap/react";4import tippy, { type Instance } from "tippy.js";5import { cn } from "@/lib/utils";67export interface Item { id: string; title: string; hint?: string; icon?: React.ReactNode; group?: string; run?: () => void; data?: any }8export const SuggestionList = forwardRef<{ onKeyDown: (p: { event: KeyboardEvent }) => boolean }, { items: Item[]; command: (item: Item) => void; empty?: string }>(({ items, command, empty = "No results" }, ref) => {9  const [idx, setIdx] = useState(0);10  useEffect(() => setIdx(0), [items]);11  useImperativeHandle(ref, () => ({ onKeyDown: ({ event }) => {12    if (event.key === "ArrowUp") { setIdx((i) => (i + items.length - 1) % Math.max(1, items.length)); return true; }13    if (event.key === "ArrowDown") { setIdx((i) => (i + 1) % Math.max(1, items.length)); return true; }14    if (event.key === "Enter") { if (items[idx]) command(items[idx]); return true; }15    return false;16  } }));17  useEffect(() => { document.getElementById(`sg-${idx}`)?.scrollIntoView({ block: "nearest" }); }, [idx]);18  if (!items.length) return <div className="rounded-lg border border-line bg-bg-elev px-3 py-2 text-[13px] text-fg-muted shadow-pop">{empty}</div>;19  let lastGroup: string | undefined;20  return (21    <div className="max-h-80 w-72 overflow-y-auto rounded-lg border border-line bg-bg-elev p-1 shadow-pop fade-up">22      {items.map((it, i) => { const g = it.group !== lastGroup ? it.group : null; lastGroup = it.group; return (23        <React.Fragment key={it.id}>24          {g && <div className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">{g}</div>}25          <button id={`sg-${i}`} type="button" onMouseEnter={() => setIdx(i)} onClick={() => command(it)} className={cn("flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[13px]", i === idx ? "bg-bg-sunk" : "")}>26            <span className="grid size-7 shrink-0 place-items-center rounded-md border border-line bg-bg text-fg-muted [&_svg]:size-4">{it.icon ?? "•"}</span>27            <span className="min-w-0 flex-1"><span className="block truncate font-medium">{it.title}</span>{it.hint && <span className="block truncate text-[11px] text-fg-faint">{it.hint}</span>}</span>28          </button>29        </React.Fragment>); })}30    </div>31  );32});33SuggestionList.displayName = "SuggestionList";3435/** Generic Tiptap suggestion renderer (slash commands, mentions) using ReactRenderer + tippy. */36export function makeRenderer(Component: React.ComponentType<any>, extra: Record<string, unknown> = {}) {37  return () => {38    let component: ReactRenderer<any> | null = null; let popup: Instance[] | null = null;39    return {40      onStart: (props: any) => {41        component = new ReactRenderer(Component, { props: { ...props, ...extra }, editor: props.editor });42        if (!props.clientRect) return;43        popup = tippy("body", { getReferenceClientRect: props.clientRect, appendTo: () => document.body, content: component.element, showOnCreate: true, interactive: true, trigger: "manual", placement: "bottom-start", arrow: false, theme: "none", offset: [0, 6] });44      },45      onUpdate: (props: any) => { component?.updateProps({ ...props, ...extra }); if (props.clientRect) popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect }); },46      onKeyDown: (props: any) => { if (props.event.key === "Escape") { popup?.[0]?.hide(); return true; } return component?.ref?.onKeyDown?.(props) ?? false; },47      onExit: () => { popup?.[0]?.destroy(); component?.destroy(); popup = null; component = null; },48    };49  };50}51
Files · main
Menu

Theme