spb/note-spboucher-ai
Public
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