── Tiptap Markdown Editor ── - Add full Tiptap-based WYSIWYG markdown editor (markdown-editor.tsx, 709 LOC) with bubble menu, auto-save (debounced), image drag-and-drop/paste upload, table editing, task list checkboxes, and frontmatter preservation on save. - Add slash command system (slash-command.tsx, 607 LOC) with "/" trigger for block insertion (headings, lists, tables, code blocks, images, reports) and "@" trigger for file/document mention with fuzzy search across the workspace tree. - Add ReportBlockNode (report-block-node.tsx) — custom Tiptap node that renders embedded report-json blocks as interactive ReportCard widgets inline in the editor, with expand/collapse and edit-JSON support. - Add workspace asset serving API (api/workspace/assets/[...path]/route.ts) to serve images from the workspace with proper MIME types. - Add workspace file upload orkspace/upload/route.ts) for multipart image uploads (10 MB limit, image types only), saving to assets/ directory. - Add ~500 lines of Tiptap editor CSS to globals.css (editor layout, task lists, images, tables, slash command dropdown, bubble menu toolbar, code blocks, etc.). - Add 14 @tiptap/* dependencies to apps/web/package.json (react, starter-kit, markdown, image, link, table, task-list, suggestion, placeholder, etc.). ── Document View: Edit/Read Mode Toggle ── - document-view.tsx: Add edit/read mode toggle; defaults to edit mode when a filePath is available. Lazy-loads MarkdownEditor to keep initial bundle light. - workspace/page.tsx: Pass activePath, tree, onSave, onNavigate, and onRefreshTree through to DocumentView for full editor integration with workspace navigation and tree refresh after saves. ── Subagent Session Isolation ── - agent-runner.ts: Add RunAgentOptions with optional sessionId; when set, spawns the agent with --session-key agent:main:subagent:<id> ant so file-scoped sidebar chats run in isolated sessions independent of the main agent. - route.ts (chat API): Accept sessionId from request body and forward it to runAgent. Resolve workspace file path prefixes (resolveAgentWorkspacePrefix) so tree-relative paths become agent-cwd-relative. - chat-panel.tsx: Create per-instance DefaultChatTransport that injects sessionId via body function and a ref (avoids stale closures). On file change, auto-load the most recent session and its messages. Refresh session tab list after streaming ends. Stop ongoing stream when switching sessions. - register.agent.ts: Add --session-key <key> and --lane <lane> CLI flags. - agent-via-gateway.ts: Wire sessionKey into session resolution and validation for both interactive and --stream-json code paths. - workspace.ts: Add resolveAgentWorkspacePrefix() to map workspace-root-relative paths to repo-root-relative paths for the agent process. ── Error Surfacing ── - agent-runner.ts: Add onAgentError callback extraction helpers (parseAgentErrorMessage, parseErrorBody, parseErrorFromStderr) to surface API-level errors (402 payment, rate limits, etc.) to the UI. Captures stderr for fallback error detection on non-zero exit. - route.ts: Wire onAgentError into the SSE stream as [error]-prefixed text parts. Improve onError and onClose handlers with clearer error messages and exit code reporting. - chat-message.tsx: Detect [error]-prefixed text segments and render them as styled error banners with alert icon instead of plain text. - chat-panel.tsx: Restyle the transport-level error bar with themed colors and an alert icon consistent with in-message error styling.
608 lines
18 KiB
TypeScript
608 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useLayoutEffect,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from "react";
|
|
import { Extension } from "@tiptap/core";
|
|
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
|
import { PluginKey } from "@tiptap/pm/state";
|
|
import { createPortal } from "react-dom";
|
|
import type { Editor, Range } from "@tiptap/core";
|
|
|
|
// Unique plugin keys so both suggestions can coexist
|
|
const slashCommandPluginKey = new PluginKey("slashCommand");
|
|
const fileMentionPluginKey = new PluginKey("fileMention");
|
|
|
|
// --- Types ---
|
|
|
|
export type TreeNode = {
|
|
name: string;
|
|
path: string;
|
|
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
|
icon?: string;
|
|
children?: TreeNode[];
|
|
};
|
|
|
|
type SlashItem = {
|
|
title: string;
|
|
description?: string;
|
|
icon: React.ReactNode;
|
|
category: "file" | "block";
|
|
command: (props: { editor: Editor; range: Range }) => void;
|
|
};
|
|
|
|
// --- Helpers ---
|
|
|
|
function flattenTree(nodes: TreeNode[]): TreeNode[] {
|
|
const result: TreeNode[] = [];
|
|
for (const node of nodes) {
|
|
if (node.type !== "folder") {
|
|
result.push(node);
|
|
}
|
|
if (node.children) {
|
|
result.push(...flattenTree(node.children));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function nodeTypeIcon(type: string) {
|
|
switch (type) {
|
|
case "document":
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
</svg>
|
|
);
|
|
case "object":
|
|
return (
|
|
<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>
|
|
);
|
|
case "report":
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" x2="12" y1="20" y2="10" />
|
|
<line x1="18" x2="18" y1="20" y2="4" />
|
|
<line x1="6" x2="6" y1="20" y2="14" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Block command icons ---
|
|
|
|
const headingIcon = (level: number) => (
|
|
<span className="slash-cmd-icon-text">H{level}</span>
|
|
);
|
|
|
|
const bulletListIcon = (
|
|
<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>
|
|
);
|
|
|
|
const orderedListIcon = (
|
|
<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>
|
|
);
|
|
|
|
const blockquoteIcon = (
|
|
<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>
|
|
);
|
|
|
|
const codeBlockIcon = (
|
|
<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>
|
|
);
|
|
|
|
const horizontalRuleIcon = (
|
|
<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>
|
|
);
|
|
|
|
const imageIcon = (
|
|
<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>
|
|
);
|
|
|
|
const tableIcon = (
|
|
<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>
|
|
);
|
|
|
|
const taskListIcon = (
|
|
<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>
|
|
);
|
|
|
|
const reportIcon = (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" x2="12" y1="20" y2="10" />
|
|
<line x1="18" x2="18" y1="20" y2="4" />
|
|
<line x1="6" x2="6" y1="20" y2="14" />
|
|
</svg>
|
|
);
|
|
|
|
// --- Build items ---
|
|
|
|
function buildBlockCommands(): SlashItem[] {
|
|
return [
|
|
{
|
|
title: "Heading 1",
|
|
description: "Large section heading",
|
|
icon: headingIcon(1),
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Heading 2",
|
|
description: "Medium section heading",
|
|
icon: headingIcon(2),
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Heading 3",
|
|
description: "Small section heading",
|
|
icon: headingIcon(3),
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Bullet List",
|
|
description: "Unordered list",
|
|
icon: bulletListIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Numbered List",
|
|
description: "Ordered list",
|
|
icon: orderedListIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Task List",
|
|
description: "Checklist with checkboxes",
|
|
icon: taskListIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Blockquote",
|
|
description: "Quote block",
|
|
icon: blockquoteIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Code Block",
|
|
description: "Fenced code block",
|
|
icon: codeBlockIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Horizontal Rule",
|
|
description: "Divider line",
|
|
icon: horizontalRuleIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Image",
|
|
description: "Insert image from URL",
|
|
icon: imageIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
const url = window.prompt("Image URL:");
|
|
if (url) {
|
|
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
title: "Table",
|
|
description: "Insert a 3x3 table",
|
|
icon: tableIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange(range)
|
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
.run();
|
|
},
|
|
},
|
|
{
|
|
title: "Report Chart",
|
|
description: "Interactive report-json block",
|
|
icon: reportIcon,
|
|
category: "block",
|
|
command: ({ editor, range }) => {
|
|
const template = JSON.stringify(
|
|
{
|
|
version: 1,
|
|
title: "New Report",
|
|
panels: [
|
|
{
|
|
id: "panel-1",
|
|
title: "Chart",
|
|
type: "bar",
|
|
sql: "SELECT 1 as x, 10 as y",
|
|
mapping: { xAxis: "x", yAxis: ["y"] },
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange(range)
|
|
.insertContent({
|
|
type: "reportBlock",
|
|
attrs: { config: template },
|
|
})
|
|
.run();
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function buildFileItems(tree: TreeNode[]): SlashItem[] {
|
|
const flatFiles = flattenTree(tree);
|
|
return flatFiles.map((node) => ({
|
|
title: node.name.replace(/\.md$/, ""),
|
|
description: node.path,
|
|
icon: nodeTypeIcon(node.type),
|
|
category: "file" as const,
|
|
command: ({ editor, range }: { editor: Editor; range: Range }) => {
|
|
const label = node.name.replace(/\.md$/, "");
|
|
// Insert as structured content so the link mark is applied properly
|
|
// (raw HTML strings get escaped by the Markdown extension)
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange(range)
|
|
.insertContent({
|
|
type: "text",
|
|
text: label,
|
|
marks: [
|
|
{
|
|
type: "link",
|
|
attrs: { href: node.path, target: null },
|
|
},
|
|
],
|
|
})
|
|
.run();
|
|
},
|
|
}));
|
|
}
|
|
|
|
// --- Popup Component ---
|
|
|
|
type CommandListRef = {
|
|
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
|
};
|
|
|
|
type CommandListProps = {
|
|
items: SlashItem[];
|
|
command: (item: SlashItem) => void;
|
|
};
|
|
|
|
const CommandList = forwardRef<CommandListRef, CommandListProps>(
|
|
({ items, command }, ref) => {
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [items]);
|
|
|
|
// Scroll selected into view
|
|
useEffect(() => {
|
|
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
|
|
el?.scrollIntoView({ block: "nearest" });
|
|
}, [selectedIndex]);
|
|
|
|
const selectItem = useCallback(
|
|
(index: number) => {
|
|
const item = items[index];
|
|
if (item) {
|
|
command(item);
|
|
}
|
|
},
|
|
[items, command],
|
|
);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
|
if (event.key === "ArrowUp") {
|
|
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
|
return true;
|
|
}
|
|
if (event.key === "ArrowDown") {
|
|
setSelectedIndex((i) => (i + 1) % items.length);
|
|
return true;
|
|
}
|
|
if (event.key === "Enter") {
|
|
selectItem(selectedIndex);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
}));
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="slash-cmd-popup">
|
|
<div className="slash-cmd-empty">No results</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="slash-cmd-popup" ref={listRef}>
|
|
{items.map((item, index) => (
|
|
<button
|
|
type="button"
|
|
key={`${item.category}-${item.title}`}
|
|
className={`slash-cmd-item ${index === selectedIndex ? "slash-cmd-item-active" : ""}`}
|
|
onClick={() => selectItem(index)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
>
|
|
<span className="slash-cmd-item-icon">{item.icon}</span>
|
|
<span className="slash-cmd-item-body">
|
|
<span className="slash-cmd-item-title">{item.title}</span>
|
|
{item.description && (
|
|
<span className="slash-cmd-item-desc">{item.description}</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
CommandList.displayName = "CommandList";
|
|
|
|
// --- Floating wrapper that renders into a portal ---
|
|
|
|
function SlashPopupRenderer({
|
|
items,
|
|
command,
|
|
clientRect,
|
|
componentRef,
|
|
}: {
|
|
items: SlashItem[];
|
|
command: (item: SlashItem) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
componentRef: React.RefObject<CommandListRef | null>;
|
|
}) {
|
|
const popupRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Position popup near the cursor
|
|
useLayoutEffect(() => {
|
|
if (!popupRef.current || !clientRect) {return;}
|
|
const rect = clientRect();
|
|
if (!rect) {return;}
|
|
const el = popupRef.current;
|
|
el.style.position = "fixed";
|
|
el.style.left = `${rect.left}px`;
|
|
el.style.top = `${rect.bottom + 4}px`;
|
|
el.style.zIndex = "50";
|
|
}, [clientRect, items]);
|
|
|
|
return createPortal(
|
|
<div ref={popupRef}>
|
|
<CommandList ref={componentRef} items={items} command={command} />
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// --- Shared suggestion render factory ---
|
|
|
|
function createSuggestionRenderer() {
|
|
return () => {
|
|
let container: HTMLDivElement | null = null;
|
|
let root: ReturnType<typeof import("react-dom/client").createRoot> | null = null;
|
|
const componentRef: React.RefObject<CommandListRef | null> = { current: null };
|
|
|
|
return {
|
|
onStart: (props: {
|
|
items: SlashItem[];
|
|
command: (item: SlashItem) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
}) => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
import("react-dom/client").then(({ createRoot }) => {
|
|
root = createRoot(container!);
|
|
root.render(
|
|
<SlashPopupRenderer
|
|
items={props.items}
|
|
command={props.command}
|
|
clientRect={props.clientRect}
|
|
componentRef={componentRef}
|
|
/>,
|
|
);
|
|
});
|
|
},
|
|
onUpdate: (props: {
|
|
items: SlashItem[];
|
|
command: (item: SlashItem) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
}) => {
|
|
root?.render(
|
|
<SlashPopupRenderer
|
|
items={props.items}
|
|
command={props.command}
|
|
clientRect={props.clientRect}
|
|
componentRef={componentRef}
|
|
/>,
|
|
);
|
|
},
|
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
|
if (props.event.key === "Escape") {
|
|
root?.unmount();
|
|
container?.remove();
|
|
container = null;
|
|
root = null;
|
|
return true;
|
|
}
|
|
return componentRef.current?.onKeyDown(props) ?? false;
|
|
},
|
|
onExit: () => {
|
|
root?.unmount();
|
|
container?.remove();
|
|
container = null;
|
|
root = null;
|
|
},
|
|
};
|
|
};
|
|
}
|
|
|
|
// --- Extension factories ---
|
|
|
|
/**
|
|
* "/" slash command -- markdown block commands only (headings, lists, code, etc.)
|
|
*/
|
|
export function createSlashCommand() {
|
|
const blockCommands = buildBlockCommands();
|
|
|
|
return Extension.create({
|
|
name: "slashCommand",
|
|
|
|
addOptions() {
|
|
return {
|
|
suggestion: {
|
|
char: "/",
|
|
pluginKey: slashCommandPluginKey,
|
|
startOfLine: false,
|
|
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
|
|
item.command({ editor, range });
|
|
},
|
|
items: ({ query }: { query: string }) => {
|
|
const q = query.toLowerCase();
|
|
if (!q) {return blockCommands;}
|
|
return blockCommands.filter(
|
|
(item) =>
|
|
item.title.toLowerCase().includes(q) ||
|
|
(item.description?.toLowerCase().includes(q) ?? false),
|
|
);
|
|
},
|
|
render: createSuggestionRenderer(),
|
|
} satisfies Partial<SuggestionOptions<SlashItem>>,
|
|
};
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
Suggestion({
|
|
editor: this.editor,
|
|
...this.options.suggestion,
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* "@" mention command -- workspace file cross-linking
|
|
*/
|
|
export function createFileMention(tree: TreeNode[]) {
|
|
const fileItems = buildFileItems(tree);
|
|
|
|
return Extension.create({
|
|
name: "fileMention",
|
|
|
|
addOptions() {
|
|
return {
|
|
suggestion: {
|
|
char: "@",
|
|
pluginKey: fileMentionPluginKey,
|
|
startOfLine: false,
|
|
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
|
|
item.command({ editor, range });
|
|
},
|
|
items: ({ query }: { query: string }) => {
|
|
const q = query.toLowerCase();
|
|
if (!q) {return fileItems.slice(0, 15);}
|
|
return fileItems
|
|
.filter(
|
|
(item) =>
|
|
item.title.toLowerCase().includes(q) ||
|
|
(item.description?.toLowerCase().includes(q) ?? false),
|
|
)
|
|
.slice(0, 15);
|
|
},
|
|
render: createSuggestionRenderer(),
|
|
} satisfies Partial<SuggestionOptions<SlashItem>>,
|
|
};
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
Suggestion({
|
|
editor: this.editor,
|
|
...this.options.suggestion,
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|