openclaw/apps/web/app/components/workspace/markdown-editor.tsx
kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

714 lines
24 KiB
TypeScript

"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<ReturnType<typeof setTimeout> | 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<string | null> => {
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 (
<div className="animate-pulse space-y-3 py-4 px-6">
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
</div>
);
}
return (
<div className="markdown-editor-container">
{/* Sticky top bar: save status + save button */}
<div className="editor-top-bar">
<div className="editor-top-bar-left">
{isDirty && (
<span className="editor-save-indicator editor-save-unsaved">
Unsaved changes
</span>
)}
{saveStatus === "saved" && !isDirty && (
<span className="editor-save-indicator editor-save-saved">
Saved
</span>
)}
{saveStatus === "error" && (
<span className="editor-save-indicator editor-save-error">
Save failed
</span>
)}
</div>
<div className="editor-top-bar-right">
<span className="editor-save-hint">
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S
</span>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving || !isDirty}
className="editor-save-button"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
{/* Toolbar */}
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
{/* Bubble menu for text selection */}
<BubbleMenu editor={editor}>
<div className="bubble-menu">
<BubbleButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
>
<strong>B</strong>
</BubbleButton>
<BubbleButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
>
<em>I</em>
</BubbleButton>
<BubbleButton
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
>
<s>S</s>
</BubbleButton>
<BubbleButton
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
title="Inline code"
>
{"<>"}
</BubbleButton>
<BubbleButton
active={editor.isActive("link")}
onClick={() => {
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</BubbleButton>
</div>
</BubbleMenu>
{/* Editor content */}
<div className="editor-content-area workspace-prose">
<EditorContent editor={editor} />
</div>
</div>
);
}
// --- Toolbar ---
function EditorToolbar({
editor,
onUploadImages,
}: {
editor: ReturnType<typeof useEditor>;
onUploadImages?: (files: File[]) => void;
}) {
const imageInputRef = useRef<HTMLInputElement>(null);
if (!editor) {return null;}
return (
<div className="editor-toolbar">
{/* Headings */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1"
>
H1
</ToolbarButton>
<ToolbarButton
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2"
>
H2
</ToolbarButton>
<ToolbarButton
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
title="Heading 3"
>
H3
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Inline formatting */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
>
<s>S</s>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
title="Inline code"
>
{"<>"}
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Block elements */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Bullet list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Ordered list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("taskList")}
onClick={() => editor.chain().focus().toggleTaskList().run()}
title="Task list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
title="Blockquote"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
title="Code block"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Insert items */}
<ToolbarGroup>
<ToolbarButton
active={false}
onClick={() => {
const url = window.prompt("Link URL:");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}}
title="Insert link"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => {
// 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)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</ToolbarButton>
{/* Hidden file input for image upload */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
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 = "";
}}
/>
<ToolbarButton
active={false}
onClick={() => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}}
title="Insert table"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => {
editor.chain().focus().setHorizontalRule().run();
}}
title="Horizontal rule"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" x2="22" y1="12" y2="12" />
</svg>
</ToolbarButton>
</ToolbarGroup>
</div>
);
}
// --- Toolbar primitives ---
function ToolbarGroup({ children }: { children: React.ReactNode }) {
return <div className="editor-toolbar-group">{children}</div>;
}
function ToolbarDivider() {
return <div className="editor-toolbar-divider" />;
}
function ToolbarButton({
active,
onClick,
title,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`editor-toolbar-btn ${active ? "editor-toolbar-btn-active" : ""}`}
onClick={onClick}
title={title}
>
{children}
</button>
);
}
// --- Bubble menu button ---
function BubbleButton({
active,
onClick,
title,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`bubble-menu-btn ${active ? "bubble-menu-btn-active" : ""}`}
onClick={onClick}
title={title}
>
{children}
</button>
);
}