SPB Git
7.1 KB · 80 lines tsx
1"use client";2import { Extension, type Editor, type Range } from "@tiptap/core";3import Suggestion from "@tiptap/suggestion";4import { nanoid } from "nanoid";5import { Heading1, Heading2, Heading3, List, ListOrdered, ListChecks, Quote, Code2, Minus, Table, Image, Film, Paperclip, Sigma, SquareSigma, Lightbulb, ChevronRight, Sparkles, Link2, FileText, Database, Hash, Type, Footprints, Wand2 } from "lucide-react";6import { SuggestionList, makeRenderer, type Item } from "./suggestion-ui";78export type SlashContext = { onAI: (editor: Editor, range: Range, mode: string) => void; onUpload: (editor: Editor, range: Range, accept: string) => void; onNewPage: (editor: Editor, range: Range, type: "page" | "database") => void };9export const slashItems = (ctx: SlashContext) => (q: string): Item[] => {10  const C = (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range);11  const all: (Item & { keywords?: string })[] = [12    { id: "text", group: "Basic", title: "Text", hint: "Plain paragraph", icon: <Type />, keywords: "paragraph", run: undefined },13    { id: "h1", group: "Basic", title: "Heading 1", hint: "Section title", icon: <Heading1 />, keywords: "title h1" },14    { id: "h2", group: "Basic", title: "Heading 2", icon: <Heading2 />, keywords: "h2" },15    { id: "h3", group: "Basic", title: "Heading 3", icon: <Heading3 />, keywords: "h3" },16    { id: "bullet", group: "Basic", title: "Bulleted list", icon: <List />, keywords: "ul list" },17    { id: "ordered", group: "Basic", title: "Numbered list", icon: <ListOrdered />, keywords: "ol numbered" },18    { id: "todo", group: "Basic", title: "To-do list", hint: "Checklist", icon: <ListChecks />, keywords: "task checkbox todo" },19    { id: "toggle", group: "Basic", title: "Toggle", hint: "Collapsible block", icon: <ChevronRight />, keywords: "details collapse" },20    { id: "quote", group: "Basic", title: "Quote", icon: <Quote />, keywords: "blockquote" },21    { id: "callout", group: "Basic", title: "Callout", hint: "Highlighted note", icon: <Lightbulb />, keywords: "note info warning" },22    { id: "divider", group: "Basic", title: "Divider", icon: <Minus />, keywords: "hr rule" },23    { id: "code", group: "Advanced", title: "Code block", hint: "Syntax highlighted", icon: <Code2 />, keywords: "code snippet" },24    { id: "table", group: "Advanced", title: "Table", hint: "3 × 3 with header", icon: <Table />, keywords: "grid" },25    { id: "math", group: "Advanced", title: "Equation", hint: "Block LaTeX", icon: <SquareSigma />, keywords: "latex formula equation" },26    { id: "imath", group: "Advanced", title: "Inline equation", icon: <Sigma />, keywords: "latex inline" },27    { id: "footnote", group: "Advanced", title: "Footnote", icon: <Footprints />, keywords: "citation reference note" },28    { id: "image", group: "Media", title: "Image", hint: "Upload or paste", icon: <Image />, keywords: "picture photo" },29    { id: "video", group: "Media", title: "Video", hint: "YouTube / Vimeo URL", icon: <Film />, keywords: "youtube embed" },30    { id: "file", group: "Media", title: "File", hint: "Attach PDF, DOCX, …", icon: <Paperclip />, keywords: "attachment upload pdf" },31    { id: "link", group: "Links", title: "Link to page", hint: "Type @ to mention", icon: <Link2 />, keywords: "mention page" },32    { id: "page", group: "Links", title: "New sub-page", icon: <FileText />, keywords: "subpage child" },33    { id: "db", group: "Links", title: "New database", hint: "Table / board / calendar", icon: <Database />, keywords: "database table kanban" },34    { id: "ai-write", group: "AI", title: "Ask AI to write", hint: "Generate content here", icon: <Sparkles />, keywords: "ai generate write" },35    { id: "ai-continue", group: "AI", title: "Continue writing", icon: <Wand2 />, keywords: "ai continue" },36    { id: "ai-summary", group: "AI", title: "Summarize page", icon: <Hash />, keywords: "ai summary tldr" },37  ];38  const run = (id: string) => (editor: Editor, range: Range) => {39    switch (id) {40      case "text": return C(editor, range).setParagraph().run();41      case "h1": return C(editor, range).setHeading({ level: 1 }).run();42      case "h2": return C(editor, range).setHeading({ level: 2 }).run();43      case "h3": return C(editor, range).setHeading({ level: 3 }).run();44      case "bullet": return C(editor, range).toggleBulletList().run();45      case "ordered": return C(editor, range).toggleOrderedList().run();46      case "todo": return C(editor, range).toggleTaskList().run();47      case "toggle": return C(editor, range).insertContent({ type: "details", attrs: { open: true }, content: [{ type: "detailsSummary", content: [{ type: "text", text: "Toggle" }] }, { type: "detailsContent", content: [{ type: "paragraph" }] }] }).run();48      case "quote": return C(editor, range).toggleBlockquote().run();49      case "callout": return C(editor, range).insertContent({ type: "callout", attrs: { kind: "note", icon: "💡" }, content: [{ type: "paragraph" }] }).run();50      case "divider": return C(editor, range).setHorizontalRule().run();51      case "code": return C(editor, range).toggleCodeBlock().run();52      case "table": return C(editor, range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();53      case "math": return C(editor, range).insertContent({ type: "blockMath", attrs: { latex: "E = mc^2" } }).run();54      case "imath": return C(editor, range).insertContent({ type: "inlineMath", attrs: { latex: "x^2" } }).run();55      case "footnote": { const id = String(1 + editor.state.doc.content.content.filter((n: any) => n.type.name === "footnote").length); C(editor, range).insertContent({ type: "footnoteRef", attrs: { id } }).run(); editor.chain().focus("end").insertContent({ type: "footnote", attrs: { id }, content: [{ type: "text", text: "Footnote text" }] }).run(); return; }56      case "image": return ctx.onUpload(editor, range, "image/*");57      case "file": return ctx.onUpload(editor, range, "*/*");58      case "video": { const url = window.prompt("Video URL (YouTube / Vimeo)"); if (url) C(editor, range).setYoutubeVideo({ src: url }).run(); else C(editor, range).run(); return; }59      case "link": return C(editor, range).insertContent("@").run();60      case "page": return ctx.onNewPage(editor, range, "page");61      case "db": return ctx.onNewPage(editor, range, "database");62      case "ai-write": return ctx.onAI(editor, range, "write");63      case "ai-continue": return ctx.onAI(editor, range, "continue");64      case "ai-summary": return ctx.onAI(editor, range, "summarize");65    }66  };67  const qq = q.toLowerCase();68  return all.filter((i) => !qq || i.title.toLowerCase().includes(qq) || i.keywords?.includes(qq)).map((i) => ({ ...i, data: run(i.id) })).slice(0, 30);69};70export const SlashCommand = Extension.create<{ ctx: SlashContext }>({71  name: "slashCommand",72  addOptions() { return { ctx: { onAI: () => {}, onUpload: () => {}, onNewPage: () => {} } }; },73  addProseMirrorPlugins() {74    return [Suggestion({ editor: this.editor, char: "/", startOfLine: false, allowSpaces: false,75      command: ({ editor, range, props }: any) => { props.data?.(editor, range); },76      items: ({ query }: { query: string }) => slashItems(this.options.ctx)(query),77      render: makeRenderer(SuggestionList, { empty: "No block matches" }) })];78  },79});80
Files · main
Menu

Theme