"use client"; import { useEditor, EditorContent } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; import { Table } from "@tiptap/extension-table"; import TableRow from "@tiptap/extension-table-row"; import TableCell from "@tiptap/extension-table-cell"; import TableHeader from "@tiptap/extension-table-header"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; import Placeholder from "@tiptap/extension-placeholder"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node"; import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command"; import { isWorkspaceLink } from "@/lib/workspace-links"; // --- Types --- export type MarkdownEditorProps = { /** The markdown body (frontmatter already stripped by parent). */ content: string; /** Original raw file content including frontmatter, used to preserve it on save. */ rawContent?: string; filePath: string; tree: TreeNode[]; onSave?: () => void; onNavigate?: (path: string) => void; /** Optional search function from useSearchIndex for fuzzy @ mention search. */ searchFn?: MentionSearchFn; }; // --- Main component --- /** Extract YAML frontmatter (if any) from raw file content. */ function extractFrontmatter(raw: string): string { const match = raw.match(/^(---\s*\n[\s\S]*?\n---\s*\n)/); return match ? match[1] : ""; } export function MarkdownEditor({ content, rawContent, filePath, tree, onSave, onNavigate, searchFn, }: MarkdownEditorProps) { const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); const [isDirty, setIsDirty] = useState(false); // Tracks the `content` prop so we can detect external updates (parent re-fetch). // Only updated when the prop itself changes -- never on save. const lastPropContentRef = useRef(content); const saveTimerRef = useRef | null>(null); // Preserve frontmatter so save can prepend it back const frontmatterRef = useRef(extractFrontmatter(rawContent ?? "")); // "/" for block commands, "@" for workspace search (files + entries) const slashCommand = useMemo(() => createSlashCommand(), []); const fileMention = useMemo( () => searchFn ? createWorkspaceMention(searchFn) : createFileMention(tree), // searchFn from useSearchIndex is a stable ref-based function, so this // only re-runs on initial mount or if tree changes as fallback. // eslint-disable-next-line react-hooks/exhaustive-deps [searchFn, tree], ); const editor = useEditor({ extensions: [ StarterKit.configure({ codeBlock: { HTMLAttributes: { class: "code-block" }, }, }), Markdown.configure({ markedOptions: { gfm: true }, }), Image.configure({ inline: false, allowBase64: true, HTMLAttributes: { class: "editor-image" }, }), Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { class: "editor-link", // Prevent browser from following workspace links as real URLs rel: "noopener", }, }), Table.configure({ resizable: false }), TableRow, TableCell, TableHeader, TaskList, TaskItem.configure({ nested: true }), Placeholder.configure({ placeholder: "Start writing, or type / for commands...", }), ReportBlockNode, slashCommand, fileMention, ], // Parse initial content as markdown (not HTML -- the default) content: preprocessReportBlocks(content), contentType: "markdown", immediatelyRender: false, onUpdate: () => { setIsDirty(true); setSaveStatus("idle"); }, }); // --- Image upload helper --- const uploadImage = useCallback( async (file: File): Promise => { const form = new FormData(); form.append("file", file); try { const res = await fetch("/api/workspace/upload", { method: "POST", body: form, }); if (!res.ok) {return null;} const data = await res.json(); // Return a URL the browser can fetch to display the image return `/api/workspace/assets/${(data.path as string).replace(/^assets\//, "")}`; } catch { return null; } }, [], ); /** Upload one or more image Files and insert them at the current cursor. */ const insertUploadedImages = useCallback( async (files: File[]) => { if (!editor) {return;} for (const file of files) { const url = await uploadImage(file); if (url) { editor.chain().focus().setImage({ src: url, alt: file.name }).run(); } } }, [editor, uploadImage], ); // --- Drop & paste handlers for images --- useEffect(() => { if (!editor) {return;} const editorElement = editor.view.dom; // Prevent the browser default (open file in tab) and upload instead const handleDrop = (event: DragEvent) => { if (!event.dataTransfer?.files?.length) {return;} const imageFiles = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/"), ); if (imageFiles.length === 0) {return;} event.preventDefault(); event.stopPropagation(); void insertUploadedImages(imageFiles); }; // Also prevent dragover so the browser doesn't hijack the drop const handleDragOver = (event: DragEvent) => { if (event.dataTransfer?.types?.includes("Files")) { event.preventDefault(); } }; const handlePaste = (event: ClipboardEvent) => { if (!event.clipboardData) {return;} // 1. Handle pasted image files (e.g. screenshots) const imageFiles = Array.from(event.clipboardData.files).filter((f) => f.type.startsWith("image/"), ); if (imageFiles.length > 0) { event.preventDefault(); event.stopPropagation(); void insertUploadedImages(imageFiles); return; } // 2. Handle pasted text that looks like a local image path or file:// URL const text = event.clipboardData.getData("text/plain"); if (!text) {return;} const isLocalPath = text.startsWith("file://") || /^(\/|~\/|[A-Z]:\\).*\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(text.trim()); if (isLocalPath) { event.preventDefault(); event.stopPropagation(); // Insert as an image node directly -- the browser can't fetch file:// but // the user likely has the file accessible on their machine. We insert the // cleaned path; the asset serving route won't help here but at least the // markdown ![](path) will be correct. const cleanPath = text.trim().replace(/^file:\/\//, ""); editor?.chain().focus().setImage({ src: cleanPath }).run(); } }; editorElement.addEventListener("drop", handleDrop); editorElement.addEventListener("dragover", handleDragOver); editorElement.addEventListener("paste", handlePaste); return () => { editorElement.removeEventListener("drop", handleDrop); editorElement.removeEventListener("dragover", handleDragOver); editorElement.removeEventListener("paste", handlePaste); }; }, [editor, insertUploadedImages]); // Handle link clicks for workspace navigation. // Links are real URLs like /workspace?path=... so clicking them navigates // within the same tab. We intercept to avoid a full page reload. useEffect(() => { if (!editor || !onNavigate) {return;} const handleClick = (event: MouseEvent) => { const target = event.target as HTMLElement; const link = target.closest("a"); if (!link) {return;} const href = link.getAttribute("href"); if (!href) {return;} // Intercept /workspace?... links to handle via client-side state if (isWorkspaceLink(href)) { event.preventDefault(); event.stopPropagation(); onNavigate(href); } }; const editorElement = editor.view.dom; editorElement.addEventListener("click", handleClick, true); return () => editorElement.removeEventListener("click", handleClick, true); }, [editor, onNavigate]); // Save handler const handleSave = useCallback(async () => { if (!editor || saving) {return;} setSaving(true); setSaveStatus("idle"); try { // Serialize editor content back to markdown // The Markdown extension adds getMarkdown() to the editor instance const editorAny = editor as unknown as { getMarkdown?: () => string }; let markdown: string; if (typeof editorAny.getMarkdown === "function") { markdown = editorAny.getMarkdown(); } else { // Fallback: use HTML output markdown = editor.getHTML(); } // Convert report block HTML back to ```report-json``` fenced blocks const bodyContent = postprocessReportBlocks(markdown); // Prepend preserved frontmatter so it isn't lost on save const finalContent = frontmatterRef.current + bodyContent; // Virtual paths (~skills/*) use the virtual-file API const saveEndpoint = filePath.startsWith("~") ? "/api/workspace/virtual-file" : "/api/workspace/file"; const res = await fetch(saveEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: filePath, content: finalContent }), }); if (res.ok) { setSaveStatus("saved"); setIsDirty(false); // Sync the prop tracker to the body we just saved so the external-update // effect doesn't see a mismatch and reset the editor. lastPropContentRef.current = content; onSave?.(); // Clear "saved" indicator after 2s if (saveTimerRef.current) {clearTimeout(saveTimerRef.current);} saveTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2000); } else { setSaveStatus("error"); } } catch { setSaveStatus("error"); } finally { setSaving(false); } }, [editor, filePath, saving, onSave]); // Keyboard shortcut: Cmd/Ctrl+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); void handleSave(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [handleSave]); // Update content when file changes externally (parent re-fetched the file) useEffect(() => { if (!editor || isDirty) {return;} if (content !== lastPropContentRef.current) { lastPropContentRef.current = content; // Also update frontmatter in case the raw content changed frontmatterRef.current = extractFrontmatter(rawContent ?? ""); const processed = preprocessReportBlocks(content); editor.commands.setContent(processed, { contentType: "markdown" }); setIsDirty(false); } }, [content, rawContent, editor, isDirty]); if (!editor) { return (
); } return (
{/* Sticky top bar: save status + save button */}
{isDirty && ( Unsaved changes )} {saveStatus === "saved" && !isDirty && ( Saved )} {saveStatus === "error" && ( Save failed )}
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S
{/* Toolbar */} {/* Bubble menu for text selection */}
editor.chain().focus().toggleBold().run()} title="Bold" > B editor.chain().focus().toggleItalic().run()} title="Italic" > I editor.chain().focus().toggleStrike().run()} title="Strikethrough" > S editor.chain().focus().toggleCode().run()} title="Inline code" > {"<>"} { if (editor.isActive("link")) { editor.chain().focus().unsetLink().run(); } else { const url = window.prompt("URL:"); if (url) { editor.chain().focus().setLink({ href: url }).run(); } } }} title="Link" >
{/* Editor content */}
); } // --- Toolbar --- function EditorToolbar({ editor, onUploadImages, }: { editor: ReturnType; onUploadImages?: (files: File[]) => void; }) { const imageInputRef = useRef(null); if (!editor) {return null;} return (
{/* Headings */} editor.chain().focus().toggleHeading({ level: 1 }).run()} title="Heading 1" > H1 editor.chain().focus().toggleHeading({ level: 2 }).run()} title="Heading 2" > H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} title="Heading 3" > H3 {/* Inline formatting */} editor.chain().focus().toggleBold().run()} title="Bold" > B editor.chain().focus().toggleItalic().run()} title="Italic" > I editor.chain().focus().toggleStrike().run()} title="Strikethrough" > S editor.chain().focus().toggleCode().run()} title="Inline code" > {"<>"} {/* Block elements */} editor.chain().focus().toggleBulletList().run()} title="Bullet list" > editor.chain().focus().toggleOrderedList().run()} title="Ordered list" > editor.chain().focus().toggleTaskList().run()} title="Task list" > editor.chain().focus().toggleBlockquote().run()} title="Blockquote" > editor.chain().focus().toggleCodeBlock().run()} title="Code block" > {/* Insert items */} { const url = window.prompt("Link URL:"); if (url) { editor.chain().focus().setLink({ href: url }).run(); } }} title="Insert link" > { // Open file picker for local images; shift-click for URL input if (onUploadImages) { imageInputRef.current?.click(); } else { const url = window.prompt("Image URL:"); if (url) { editor.chain().focus().setImage({ src: url }).run(); } } }} title="Insert image (click to upload, or drag & drop)" > {/* Hidden file input for image upload */} { const files = Array.from(e.target.files ?? []); if (files.length > 0 && onUploadImages) { onUploadImages(files); } // Reset so the same file can be picked again e.target.value = ""; }} /> { editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); }} title="Insert table" > { editor.chain().focus().setHorizontalRule().run(); }} title="Horizontal rule" >
); } // --- Toolbar primitives --- function ToolbarGroup({ children }: { children: React.ReactNode }) { return
{children}
; } function ToolbarDivider() { return
; } function ToolbarButton({ active, onClick, title, children, }: { active: boolean; onClick: () => void; title: string; children: React.ReactNode; }) { return ( ); } // --- Bubble menu button --- function BubbleButton({ active, onClick, title, children, }: { active: boolean; onClick: () => void; title: string; children: React.ReactNode; }) { return ( ); }