spb/note-spboucher-ai
Public
1"use client";2import { useEditor, EditorContent, type Editor as TEditor } from "@tiptap/react";3import { BubbleMenu } from "@tiptap/react/menus";4import StarterKit from "@tiptap/starter-kit";5import { TaskList, TaskItem } from "@tiptap/extension-list";6import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";7import Image from "@tiptap/extension-image";8import Highlight from "@tiptap/extension-highlight";9import Subscript from "@tiptap/extension-subscript";10import Superscript from "@tiptap/extension-superscript";11import TextAlign from "@tiptap/extension-text-align";12import Youtube from "@tiptap/extension-youtube";13import { Details, DetailsSummary, DetailsContent } from "@tiptap/extension-details";14import { Placeholder, CharacterCount, Dropcursor, Gapcursor, TrailingNode } from "@tiptap/extensions";15import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";16import { Mathematics } from "@tiptap/extension-mathematics";17import FileHandler from "@tiptap/extension-file-handler";18import { DragHandle } from "@tiptap/extension-drag-handle-react";19import { createLowlight, common } from "lowlight";20import { useCallback, useEffect, useMemo, useRef, useState } from "react";21import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Highlighter, Link2, Sparkles, Heading2, List, Quote, GripVertical, Subscript as SubIcon, Superscript as SupIcon, Heading1, ListChecks, ListOrdered, Undo2, Redo2, Image as ImageIcon, Table as TableIcon, IndentDecrease, IndentIncrease, Keyboard } from "lucide-react";22import { Callout, AIBlock, FileBlock, FootnoteRef, Footnote, BlockId } from "./extensions/nodes";23import { SlashCommand } from "./extensions/slash";24import { PageMention } from "./extensions/mention";25import { AIInline, type AIInlineState } from "./AIInline";26import { cn } from "@/lib/utils";27import { toast } from "@/components/ui/toast";28import type { PMNode } from "@/lib/editor/text";2930const lowlight = createLowlight(common);31export type EditorHandle = TEditor;3233export function uploadFile(file: File, pageId?: string | null): Promise<{ id: string; url: string; name: string; size: number; mime: string }> {34 const fd = new FormData(); fd.append("file", file); if (pageId) fd.append("pageId", pageId);35 return fetch("/api/attachments", { method: "POST", body: fd }).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error ?? "Upload failed"); return j; });36}3738export function Editor({ content, onChange, onReady, pageId, editable = true, placeholder = "Write, or press “/” for blocks and “@” to link pages…", className, autofocus }: { content: PMNode; onChange?: (doc: PMNode, editor: TEditor) => void; onReady?: (e: TEditor) => void; pageId?: string | null; editable?: boolean; placeholder?: string; className?: string; autofocus?: boolean }) {39 const [ai, setAi] = useState<AIInlineState | null>(null); const [focused, setFocused] = useState(false);40 const fileInput = useRef<HTMLInputElement>(null); const pendingRange = useRef<{ from: number; to: number } | null>(null);41 const insertUploaded = useCallback(async (editor: TEditor, files: File[], pos?: number) => {42 for (const f of files) {43 try {44 const up = await uploadFile(f, pageId);45 const node = f.type.startsWith("image/") ? { type: "image", attrs: { src: up.url, alt: up.name, title: up.name } } : { type: "fileBlock", attrs: { src: up.url, name: up.name, size: up.size, mime: up.mime, attachmentId: up.id } };46 if (pos != null) editor.chain().focus().insertContentAt(pos, node).run(); else editor.chain().focus().insertContent(node).run();47 } catch (e: any) { toast("Upload failed", { description: e.message, tone: "danger" }); }48 }49 }, [pageId]);5051 const extensions = useMemo(() => [52 StarterKit.configure({ codeBlock: false, link: { openOnClick: false, autolink: true, defaultProtocol: "https", HTMLAttributes: { rel: "noopener noreferrer" } }, heading: { levels: [1, 2, 3] } }),53 TaskList, TaskItem.configure({ nested: true }),54 Table.configure({ resizable: true, lastColumnResizable: false }), TableRow, TableHeader, TableCell,55 Image.configure({ inline: false, allowBase64: false }), Highlight.configure({ multicolor: false }), Subscript, Superscript, TextAlign.configure({ types: ["heading", "paragraph"] }),56 Youtube.configure({ nocookie: true, width: 640, height: 360 }),57 Details.configure({ persist: true, HTMLAttributes: { class: "details" } }), DetailsSummary, DetailsContent,58 Placeholder.configure({ placeholder: ({ node }) => (node.type.name === "heading" ? "Heading" : node.type.name === "detailsSummary" ? "Toggle title" : placeholder), includeChildren: false }),59 CharacterCount, Dropcursor.configure({ color: "var(--accent)", width: 2 }), Gapcursor, TrailingNode,60 CodeBlockLowlight.configure({ lowlight, defaultLanguage: "plaintext" }),61 Mathematics.configure({ katexOptions: { throwOnError: false }, inlineOptions: { onClick: (node, pos) => { const latex = window.prompt("Inline LaTeX", node.attrs.latex); if (latex != null) editorRef.current?.chain().setNodeSelection(pos).updateInlineMath({ latex }).focus().run(); } }, blockOptions: { onClick: (node, pos) => { const latex = window.prompt("Block LaTeX", node.attrs.latex); if (latex != null) editorRef.current?.chain().setNodeSelection(pos).updateBlockMath({ latex }).focus().run(); } } }),62 FileHandler.configure({ allowedMimeTypes: undefined, onDrop: (editor, files, pos) => { insertUploaded(editor, files, pos); }, onPaste: (editor, files) => { insertUploaded(editor, files); } }),63 Callout, AIBlock, FileBlock, FootnoteRef, Footnote, BlockId, PageMention,64 SlashCommand.configure({ ctx: {65 onAI: (editor, range, mode) => { editor.chain().focus().deleteRange(range).run(); setAi({ mode, selection: mode === "write" ? "" : editor.getText(), replaceRange: null, insertAt: editor.state.selection.from, pageId }); },66 onUpload: (editor, range, accept) => { editor.chain().focus().deleteRange(range).run(); pendingRange.current = null; if (fileInput.current) { fileInput.current.accept = accept; fileInput.current.click(); } },67 onNewPage: async (editor, range, type) => { editor.chain().focus().deleteRange(range).run(); const p = await fetch("/api/pages", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: type === "database" ? "New database" : "New page", parentId: pageId ?? null, type: type === "database" ? "database" : "page", section: type === "database" ? "databases" : undefined }) }).then((r) => r.json()); editor.chain().focus().insertContent([{ type: "mention", attrs: { id: p.id, label: p.title } }, { type: "text", text: " " }]).run(); },68 } }),69 ], [pageId, placeholder, insertUploaded]);7071 const editorRef = useRef<TEditor | null>(null);72 const editor = useEditor({ extensions, content: content as any, editable, immediatelyRender: false, autofocus: autofocus ? "end" : false, editorProps: { attributes: { class: "ProseMirror", spellcheck: "true" }, handleKeyDown: (_v, e) => { if ((e.metaKey || e.ctrlKey) && e.key === "j") { e.preventDefault(); openAIForSelection(); return true; } return false; } }, onUpdate: ({ editor }) => onChange?.(editor.getJSON() as PMNode, editor), onCreate: ({ editor }) => { editorRef.current = editor; onReady?.(editor); }, onFocus: () => setFocused(true), onBlur: () => setTimeout(() => setFocused(false), 150) });73 useEffect(() => { editorRef.current = editor; }, [editor]);74 useEffect(() => { if (editor && editor.isEditable !== editable) editor.setEditable(editable); }, [editor, editable]);7576 const openAIForSelection = useCallback((mode = "custom") => {77 const e = editorRef.current; if (!e) return;78 const { from, to } = e.state.selection; const sel = e.state.doc.textBetween(from, to, "\n");79 setAi({ mode, selection: sel || e.getText(), replaceRange: sel ? { from, to } : null, insertAt: to, pageId });80 }, [pageId]);8182 const setLink = useCallback(() => {83 const e = editorRef.current; if (!e) return; const prev = e.getAttributes("link").href as string | undefined;84 const url = window.prompt("Link URL", prev ?? "https://"); if (url === null) return;85 if (!url) { e.chain().focus().extendMarkRange("link").unsetLink().run(); return; }86 e.chain().focus().extendMarkRange("link").setLink({ href: url }).run();87 }, []);8889 if (!editor) return <div className={cn("note-editor min-h-[40vh] pulse-soft", className)} />;90 const B = ({ active, onClick, title, children }: { active?: boolean; onClick: () => void; title: string; children: React.ReactNode }) => <button type="button" title={title} onMouseDown={(e) => e.preventDefault()} onClick={onClick} className={cn("grid size-7 place-items-center rounded-md text-fg-muted hover:bg-bg-sunk hover:text-fg [&_svg]:size-4", active && "bg-accent-soft text-accent")}>{children}</button>;91 return (92 <div className={cn("note-editor relative", className)}>93 <input ref={fileInput} type="file" multiple className="hidden" onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length && editorRef.current) insertUploaded(editorRef.current, files); e.target.value = ""; }} />94 {editable && (95 <DragHandle editor={editor} className="drag-handle" computePositionConfig={{ placement: "left-start" }}><GripVertical className="size-4" /></DragHandle>96 )}97 {editable && (98 <BubbleMenu editor={editor} shouldShow={({ editor, state }) => !state.selection.empty && !editor.isActive("codeBlock") && !editor.isActive("image")} options={{ placement: "top", offset: 8 }}>99 <div className="flex items-center gap-0.5 rounded-lg border border-line bg-bg-elev p-1 shadow-pop fade-up">100 <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => openAIForSelection("custom")} className="mr-1 flex h-7 items-center gap-1 rounded-md bg-ai-soft px-2 text-[12px] font-medium text-ai hover:bg-ai/20"><Sparkles className="size-3.5" /> AI</button>101 <B title="Bold ⌘B" active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}><Bold /></B>102 <B title="Italic ⌘I" active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic /></B>103 <B title="Underline ⌘U" active={editor.isActive("underline")} onClick={() => editor.chain().focus().toggleUnderline().run()}><UnderlineIcon /></B>104 <B title="Strike" active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough /></B>105 <B title="Highlight" active={editor.isActive("highlight")} onClick={() => editor.chain().focus().toggleHighlight().run()}><Highlighter /></B>106 <B title="Code ⌘E" active={editor.isActive("code")} onClick={() => editor.chain().focus().toggleCode().run()}><Code /></B>107 <B title="Subscript" active={editor.isActive("subscript")} onClick={() => editor.chain().focus().toggleSubscript().run()}><SubIcon /></B>108 <B title="Superscript" active={editor.isActive("superscript")} onClick={() => editor.chain().focus().toggleSuperscript().run()}><SupIcon /></B>109 <span className="mx-0.5 h-5 w-px bg-line" />110 <B title="Link ⌘K" active={editor.isActive("link")} onClick={setLink}><Link2 /></B>111 <B title="Heading" active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 /></B>112 <B title="List" active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}><List /></B>113 <B title="Quote" active={editor.isActive("blockquote")} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote /></B>114 </div>115 </BubbleMenu>116 )}117 <EditorContent editor={editor} />118 {editable && focused && (119 <div className="fixed inset-x-0 bottom-0 z-30 flex items-center gap-0.5 overflow-x-auto border-t border-line bg-bg-elev/95 px-1 py-1 backdrop-blur md:hidden safe-bottom" onMouseDown={(e) => e.preventDefault()} onTouchStart={(e) => { /* keep focus */ }}>120 <button className="flex h-9 shrink-0 items-center gap-1 rounded-md bg-ai-soft px-2 text-[12px] font-medium text-ai" onClick={() => openAIForSelection("custom")}><Sparkles className="size-4" /> AI</button>121 {([122 ["Undo", <Undo2 key="u" />, () => editor.chain().focus().undo().run(), false], ["Redo", <Redo2 key="r" />, () => editor.chain().focus().redo().run(), false],123 ["Bold", <Bold key="b" />, () => editor.chain().focus().toggleBold().run(), editor.isActive("bold")], ["Italic", <Italic key="i" />, () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic")],124 ["H1", <Heading1 key="h1" />, () => editor.chain().focus().toggleHeading({ level: 1 }).run(), editor.isActive("heading", { level: 1 })], ["H2", <Heading2 key="h2" />, () => editor.chain().focus().toggleHeading({ level: 2 }).run(), editor.isActive("heading", { level: 2 })],125 ["List", <List key="l" />, () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList")], ["Numbered", <ListOrdered key="o" />, () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList")], ["Checklist", <ListChecks key="c" />, () => editor.chain().focus().toggleTaskList().run(), editor.isActive("taskList")],126 ["Outdent", <IndentDecrease key="od" />, () => editor.chain().focus().liftListItem("listItem").run() || editor.chain().focus().liftListItem("taskItem").run(), false], ["Indent", <IndentIncrease key="id" />, () => editor.chain().focus().sinkListItem("listItem").run() || editor.chain().focus().sinkListItem("taskItem").run(), false],127 ["Quote", <Quote key="q" />, () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote")], ["Code", <Code key="cd" />, () => editor.chain().focus().toggleCodeBlock().run(), editor.isActive("codeBlock")],128 ["Table", <TableIcon key="t" />, () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), false], ["Image", <ImageIcon key="im" />, () => { if (fileInput.current) { fileInput.current.accept = "image/*"; fileInput.current.click(); } }, false], ["Link", <Link2 key="lk" />, setLink, editor.isActive("link")],129 ] as [string, React.ReactNode, () => void, boolean][]).map(([title, icon, fn, active]) => <button key={title} title={title} className={cn("grid size-9 shrink-0 place-items-center rounded-md text-fg-muted [&_svg]:size-[18px]", active && "bg-accent-soft text-accent")} onClick={fn}>{icon}</button>)}130 </div>131 )}132 {ai && <AIInline state={ai} editor={editor} onClose={() => setAi(null)} />}133 </div>134 );135}136