openclaw/apps/web/app/components/workspace/markdown-editor.tsx
kumarabhirup fe15ab44dc
Dench workspace: virtual folders (Skills, Memories, Chats), landing page, and unified workspace layout
Add virtual folder system that surfaces Skills, Memories, and Chat sessions
in the workspace sidebar alongside real dench files. Rearchitect the home
page into a landing hub and move the ChatPanel into the workspace as the
default view.

New API route — virtual-file:
- apps/web/app/api/workspace/virtual-file/route.ts: new GET/POST API that
  resolves virtual paths (~skills/*, ~memories/*) to absolute filesystem
  paths in ~/.openclaw/skills/ d ~/.openclaw/workspace/. Includes path
  traversal protection and directory allowlisting.

Tree API — virtual folder builders:
- apps/web/app/api/workspace/tree/route.ts: add `virtual` field to TreeNode
  type. Add ildSkillsVirtualFolder() scanning ~/.openclaw/skills/ and
  ~/.openclaw/workspace/skills/ with SKILL.md frontmatter parsing (name +
  emoji). Add buildMemoriesVirtualFolder() scanning MEMORY.md and daily
  logs from ~/.openclaw/workspace/memory/. Virtual folders are appended
  after real workspace entries and are also returned when no dench root
  exists.

File manager tree — virtual node awareness:
- apps/web/app/components/workspace/file-manager-tree.tsx: add isVirtualNode()
  helper and RESERVED_FOLDER_NAMES set (Chats, Skills, Memories). Virtual
  nodes show a lock badge, disable drag-and-drop, block rename/delete, and
  reject reserved names during create/rename. Add ChatBubbleIcon for ~chats/
  paths.

Markdown editor — virtual path routing:
- apps/web/app/components/workspace/markdown-editor.tsx: save to
  /api/workspace/virtual-file for paths starting with ~ instead of the
  regular /api/workspace/file endpoint.

Home page redesign:
- apps/web/app/page.tsx: replace the chat-first layout (SidebarhatPanel)
  with a branded landing page showing the OpenClaw Dench heading, tagline,
  and a single "Open Workspace" CTA linking to /workspace.

Workspace page — unified layout with integrated chat:
- apps/web/app/workspace/page.tsx: ChatPanel is now the default main view
  when no file is selected. Add session fetching from /api/web-sessions and
  build an enhanced tree with a virtual "Chats" folder listing all sessions.
  Clicking ~chats/<id> loads the session; clicking ~chats starts a new one.
  Add isVirtualPath()/fileApiUrl() helpers for virtual file reads. Add a
  "Back to chat" button in the top bar alongside the chat sidebar toggle.

Sidebar + empty-state cosmetic updates:
- apps/web/app/components/workspace/workspace-sidebar.tsx: rename BackIcon
  to HomeIcon (house SVG), change label from "Back to Chat" to "Home".
- apps/web/app/components/workspace/empty-state.tsx: update link text from
  "Back to Chat" to "Back to Home".
2026-02-11 22:09:59 -08:00

731 lines
25 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;
/** Switch to read-only mode (renders a "Read" button in the top bar). */
onSwitchToRead?: () => 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,
onSwitchToRead,
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();
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();
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/*, ~memories/*) 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();
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 + read toggle */}
<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={handleSave}
disabled={saving || !isDirty}
className="editor-save-button"
>
{saving ? "Saving..." : "Save"}
</button>
{onSwitchToRead && (
<button
type="button"
onClick={onSwitchToRead}
className="editor-mode-toggle"
title="Switch to read mode"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Read</span>
</button>
)}
</div>
</div>
{/* Toolbar */}
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
{/* Bubble menu for text selection */}
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<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>
);
}