0.5 缩减重构

This commit is contained in:
lix-2026
2026-04-13 19:21:42 +08:00
parent af92c4b149
commit 71fb1aee7e
2023 changed files with 21113 additions and 394493 deletions
@@ -0,0 +1,61 @@
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AiAgentPanel } from "./AiAgentPanel";
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="scroll-area" className={className}>
{children}
</div>
),
}));
describe("AiAgentPanel", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Element.prototype.scrollIntoView = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("空状态会展示全局 AI 标题与能力说明", () => {
act(() => {
root.render(<AiAgentPanel />);
});
expect(container.textContent).toContain("全局 AI");
expect(container.textContent).toContain("自动工具编排");
expect(container.textContent).toContain("联网检索");
expect(container.textContent).toContain("LightRAG");
expect(container.textContent).toContain("跨页面文档");
expect(container.textContent).toContain("图片 OCR");
});
it("可以切换工具活动面板显隐", () => {
act(() => {
root.render(<AiAgentPanel />);
});
const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]');
expect(toggleButton).not.toBeNull();
expect(container.textContent).toContain("本轮活动");
act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("本轮活动");
});
});
@@ -1,11 +1,27 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Command,
DatabaseZap,
FileSearch,
Image,
PanelRightClose,
PanelRightOpen,
RefreshCcw,
Search,
SendHorizontal,
Sparkles,
SquareStop,
Trash2,
X,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { Textarea } from "@/components/ui/textarea";
type ChatMsg = { role: "user" | "assistant"; content: string };
type ToolLog =
@@ -13,6 +29,58 @@ type ToolLog =
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "error"; message: string };
type CapabilityItem = {
title: string;
description: string;
icon: LucideIcon;
};
type ToolSetChip = {
id: string;
title: string;
description: string;
};
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
const MIN_PANEL_AGENT_STEPS = 1;
const MAX_PANEL_AGENT_STEPS = 24;
const TOOLSET_CHIPS: ToolSetChip[] = [
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
{ id: "toolset.rag_read", title: "LightRAG", description: "本地知识检索" },
{ id: "toolset.docs_read", title: "跨页面文档", description: "搜索与读取工作区文档" },
{ id: "toolset.media_read", title: "图片 OCR", description: "读取图片与附件文字" },
{ id: "toolset.slash_write", title: "斜杠命令", description: "受控执行写入动作" },
];
const CAPABILITY_ITEMS: CapabilityItem[] = [
{
title: "联网检索",
description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。",
icon: Search,
},
{
title: "LightRAG",
description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。",
icon: DatabaseZap,
},
{
title: "跨页面文档",
description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。",
icon: FileSearch,
},
{
title: "图片 OCR",
description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。",
icon: Image,
},
{
title: "斜杠命令",
description: "执行受控写操作,例如创建文档或改名,需要明确确认。",
icon: Command,
},
];
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
@@ -42,16 +110,60 @@ const parseSseChunks = async (res: Response, onEvent: (event: string, dataText:
}
};
export function AiAgentPanel() {
const [input, setInput] = useState("请给出 gemini-3 tokens 价格,并提供来源链接。");
const formatJson = (value: unknown) => {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const isAbortLikeError = (error: unknown) => {
return (
(typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") ||
(error instanceof Error && error.name === "AbortError")
);
};
const clampStep = (value: number) => {
return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
};
const getLogToneClass = (log: ToolLog) => {
if (log.type === "error") {
return "border-red-500/30 bg-red-500/10";
}
if (log.type === "tool_result") {
return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10";
}
return "border-white/10 bg-white/[0.03]";
};
const getLogLabel = (log: ToolLog) => {
if (log.type === "tool_call") return "工具请求";
if (log.type === "tool_result") return log.ok ? "工具结果 · 成功" : "工具结果 · 失败";
return "运行错误";
};
export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
const [input, setInput] = useState(DEFAULT_PROMPT);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [logs, setLogs] = useState<ToolLog[]>([]);
const [running, setRunning] = useState(false);
const [maxSteps, setMaxSteps] = useState(10);
const [showLogs, setShowLogs] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const messageEndRef = useRef<HTMLDivElement | null>(null);
const canSend = useMemo(() => input.trim().length > 0 && !running, [input, running]);
const canClear = messages.length > 0 || logs.length > 0;
const assistantCount = messages.filter((message) => message.role === "assistant").length;
const userCount = messages.length - assistantCount;
useEffect(() => {
messageEndRef.current?.scrollIntoView({ block: "end" });
}, [messages, logs, showLogs]);
const stop = () => {
abortRef.current?.abort();
@@ -59,6 +171,18 @@ export function AiAgentPanel() {
setRunning(false);
};
const restoreDefaultPrompt = () => {
setInput(DEFAULT_PROMPT);
};
const clearConversation = () => {
if (running) {
stop();
}
setMessages([]);
setLogs([]);
};
const send = async () => {
const text = input.trim();
if (!text) return;
@@ -84,7 +208,7 @@ export function AiAgentPanel() {
messages: nextMessages.slice(-20),
toolChoice: {
mode: "auto",
toolSets: ["toolset.readonly", "toolset.rag_read", "toolset.docs_read", "toolset.media_read", "toolset.slash_write"],
toolSets: TOOLSET_CHIPS.map((chip) => chip.id),
},
options: { searxng: true, ai: { provider: "online" } },
}),
@@ -155,12 +279,14 @@ export function AiAgentPanel() {
} catch {
setLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
}
return;
}
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setLogs((prev) => [...prev, { type: "error", message: msg }]);
} catch (error) {
if (controller.signal.aborted || isAbortLikeError(error)) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setLogs((prev) => [...prev, { type: "error", message }]);
} finally {
abortRef.current = null;
setRunning(false);
@@ -168,97 +294,339 @@ export function AiAgentPanel() {
};
return (
<div className="flex h-[calc(100vh-80px)] w-full gap-4">
<Card className="flex w-[60%] flex-col p-3">
<div className="mb-2 text-sm font-medium"></div>
<ScrollArea className="flex-1 rounded border">
<div className="space-y-3 p-3 text-sm">
{messages.length === 0 ? <div className="text-muted-foreground"></div> : null}
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
<section className="flex h-[calc(100vh-112px)] min-h-[720px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
<Bot className="h-5 w-5" />
</div>
</ScrollArea>
<div className="mt-3 flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的问题(Enter 发送,Shift+Enter 换行)"
className="min-h-[72px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="flex flex-col gap-2">
<label className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<input
className="w-[72px] rounded border px-2 py-1 text-xs"
type="number"
min={MIN_AGENT_STEPS}
max={MAX_AGENT_STEPS}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
}}
disabled={running}
/>
</label>
<Button disabled={!canSend} onClick={() => void send()}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
<span className="h-1 w-1 rounded-full bg-white/25" />
<span className="text-sm text-white/60"> AI</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-white"> AI</h1>
<Badge className="border-sky-400/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/10" variant="outline">
</Badge>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{running ? "运行中" : "待命"}
</Badge>
{onClose ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="关闭全局 AI"
title="关闭"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="切换工具活动面板"
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={() => setShowLogs((prev) => !prev)}
>
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
<Button variant="secondary" disabled={!running} onClick={stop}>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="恢复示例问题"
title="恢复示例问题"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={restoreDefaultPrompt}
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="清空对话"
title="清空对话"
disabled={!canClear}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={clearConversation}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="停止运行"
title="停止本轮运行"
disabled={!running}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={stop}
>
<SquareStop className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
</header>
<Card className="flex w-[40%] flex-col p-3">
<div className="mb-2 text-sm font-medium"></div>
<ScrollArea className="flex-1 rounded border">
<div className="space-y-3 p-3 text-sm">
{logs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{logs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
<div className="border-b border-white/8 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-white/45">
<Sparkles className="h-3.5 w-3.5" />
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
<div className="mt-2 text-sm text-white/65"> MNOTE </div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{userCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
AI {assistantCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{logs.length}
</Badge>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<Badge
key={chip.id}
variant="outline"
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
title={chip.description}
>
{chip.title}
</Badge>
))}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
{messages.length === 0 ? (
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
<div className="flex items-start gap-3">
<div className="mt-1 inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-white/85">
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white"> MNOTE AI</div>
<div className="mt-2 text-sm leading-7 text-white/72"></div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CAPABILITY_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<Icon className="h-4 w-4 text-sky-200/80" />
{item.title}
</div>
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
</div>
);
})}
</div>
<div className="mt-4 text-sm text-white/70"></div>
</div>
</div>
</div>
) : null}
{messages.map((message, index) => {
const isUser = message.role === "user";
return (
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-sm font-semibold text-white/88">
AI
</div>
) : null}
<div
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
isUser
? "border-sky-400/25 bg-sky-500/15 text-sky-50"
: "border-white/10 bg-white/[0.04] text-white/90"
}`}
>
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
</div>
{isUser ? (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-500/18 text-sm font-semibold text-sky-50">
</div>
) : null}
</article>
);
})}
<div ref={messageEndRef} />
</div>
</ScrollArea>
<div className="border-t border-white/8 px-5 py-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<div
key={`${chip.id}-summary`}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5"
>
<span className="text-xs font-medium text-white/82">{chip.title}</span>
<span className="text-xs text-white/42">{chip.description}</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
<input
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
type="number"
min={MIN_PANEL_AGENT_STEPS}
max={MAX_PANEL_AGENT_STEPS}
step={1}
value={maxSteps}
onChange={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
setMaxSteps(clampStep(Math.floor(value)));
}}
disabled={running}
/>
</label>
<Badge className="border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/70 hover:bg-white/[0.04]" variant="outline">
{running ? "状态:生成中…" : "状态:等待输入"}
</Badge>
</div>
</div>
<div className="relative">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter Shift+Enter </div>
{running ? (
<Button
type="button"
variant="secondary"
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
onClick={stop}
>
</Button>
) : null}
<Button
type="button"
disabled={!canSend}
aria-label="发送消息"
title="发送"
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
onClick={() => void send()}
>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</ScrollArea>
</Card>
</div>
{showLogs ? (
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
<div className="border-b border-white/8 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-white/48">{running ? "AI 正在调度工具与生成回答" : "等待发起下一轮任务"}</div>
</div>
<Badge className="border-white/10 bg-white/[0.04] text-white/72 hover:bg-white/[0.04]" variant="outline">
{logs.length}
</Badge>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{logs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-7 text-white/45">
</div>
) : null}
{logs.map((log, index) => (
<div key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/42">{getLogLabel(log)}</div>
{"id" in log ? <div className="text-xs text-white/35">{log.id || "no-id"}</div> : null}
</div>
{log.type === "error" ? (
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-red-100/90">{log.message}</div>
) : null}
{log.type === "tool_call" ? (
<>
<div className="mt-3 flex items-center gap-2 text-sm font-medium text-white">
<Search className="h-4 w-4 text-sky-200/80" />
{log.tool}
</div>
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
{formatJson(log.args)}
</pre>
</>
) : null}
{log.type === "tool_result" ? (
<>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<Search className="h-4 w-4 text-emerald-200/80" />
{log.tool}
</div>
<div className="text-xs text-white/45">{log.ms}ms</div>
</div>
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
{formatJson(log.result)}
</pre>
</>
) : null}
</div>
))}
</div>
</ScrollArea>
</aside>
) : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,23 @@
"use client";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { AiAgentPanel } from "./AiAgentPanel";
export function GlobalAiAgentHost() {
const open = useAiAgentUiStore((s) => s.globalAgentOpen);
const setOpen = useAiAgentUiStore((s) => s.setGlobalAgentOpen);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
showCloseButton={false}
className="w-[min(1500px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
>
<SheetTitle className="sr-only">MNOTE AI</SheetTitle>
<AiAgentPanel onClose={() => setOpen(false)} />
</SheetContent>
</Sheet>
);
}
@@ -1,10 +1,10 @@
"use client";
"use client";
import { MessageCircle, Sparkles } from "lucide-react";
import { useBackendHealth } from "@/hooks/use-backend-health";
import { cn } from "@/lib/utils";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export function BottomToolbar() {
const status = useBackendHealth();
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
@@ -15,10 +15,10 @@ export function BottomToolbar() {
: status === "error"
? "bg-red-500"
: "bg-gray-300";
return (
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
return (
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
{status === "ok"
@@ -26,6 +26,8 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
const showInspector = usePageLayoutStore((state) => state.showInspector);
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
const backendStatus = useBackendHealth();
const globalAgentOpen = useAiAgentUiStore((s) => s.globalAgentOpen);
const toggleGlobalAgentOpen = useAiAgentUiStore((s) => s.toggleGlobalAgentOpen);
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
const isStarred = useQuery(
@@ -82,6 +84,20 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
}`}
aria-label="后端连接状态"
/>
<button
type="button"
className={cn(
"rounded-full px-3 py-1 text-sm transition-colors",
globalAgentOpen
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
)}
onClick={() => toggleGlobalAgentOpen()}
title="打开全局 AI"
>
<Sparkles className="mr-1 inline h-4 w-4" />
AI
</button>
<button
type="button"
className={cn(
@@ -4,18 +4,18 @@ import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
interface TaskResponse {
task_id: string;
status: string;
progress: number;
message?: string | null;
}
interface Props {
documentId: string;
}
interface TaskResponse {
task_id: string;
status: string;
progress: number;
message?: string | null;
}
interface Props {
documentId: string;
}
export function DocumentTaskPanel({ documentId }: Props) {
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
@@ -48,11 +48,11 @@ export function DocumentTaskPanel({ documentId }: Props) {
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, task?.task_id, useConvex]);
return (
<Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
<div>
return (
<Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
<div>
<div className="font-medium text-gray-900"> OCR </div>
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
@@ -19,6 +19,19 @@ import {
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const extractCodexMode = (text: string): CodexMode => {
const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b/i);
if (!m) return "chat";
const mode = String(m[1] ?? "").toLowerCase();
if (mode === "dev" || mode === "test" || mode === "chat") return mode;
return "chat";
};
const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
{
@@ -68,6 +81,7 @@ const DEFAULT_TOOLS: ToolName[] = [
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "info"; message: string }
| { type: "error"; message: string };
type ChatSession = {
@@ -77,6 +91,8 @@ type ChatSession = {
updatedAt: number;
messages: AgentMessage[];
toolLogs: ToolLog[];
codexSessionId?: string | null;
codexMode?: CodexMode | null;
};
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
@@ -164,7 +180,7 @@ export function DocumentAiAgentPanel({
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [page, setPage] = useState<PanelPage>("chat");
@@ -204,7 +220,7 @@ export function DocumentAiAgentPanel({
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
}
if (p === "local" || p === "online") setAiProvider(p);
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
} catch {
// ignore
@@ -241,6 +257,8 @@ export function DocumentAiAgentPanel({
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
codexSessionId: null,
codexMode: null,
};
setSessions([session]);
setActiveSessionId(id);
@@ -263,7 +281,10 @@ export function DocumentAiAgentPanel({
const title = String((x as any)?.title ?? "").trim() || "历史会话";
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
return { id, title, createdAt, updatedAt, messages, toolLogs } as ChatSession;
const codexSessionId = String((x as any)?.codexSessionId ?? "").trim() || null;
const codexModeRaw = String((x as any)?.codexMode ?? "").trim();
const codexMode = codexModeRaw === "chat" || codexModeRaw === "test" || codexModeRaw === "dev" ? (codexModeRaw as CodexMode) : null;
return { id, title, createdAt, updatedAt, messages, toolLogs, codexSessionId, codexMode } as ChatSession;
})
.filter((s) => s.id),
);
@@ -298,6 +319,8 @@ export function DocumentAiAgentPanel({
updatedAt: now,
messages,
toolLogs,
codexSessionId: null,
codexMode: null,
},
...prev,
];
@@ -323,6 +346,12 @@ export function DocumentAiAgentPanel({
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const [codexSessionDraft, setCodexSessionDraft] = useState("");
useEffect(() => {
if (aiProvider !== "codex") return;
setCodexSessionDraft(String(currentSession?.codexSessionId ?? "").trim());
}, [aiProvider, currentSession?.codexSessionId]);
const pageTitle = useMemo(() => {
switch (page) {
case "tools":
@@ -349,6 +378,8 @@ export function DocumentAiAgentPanel({
updatedAt: now,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
codexSessionId: null,
codexMode: null,
};
setSessions((prev) => normalizeSessions([next, ...prev]));
setActiveSessionId(id);
@@ -400,7 +431,17 @@ export function DocumentAiAgentPanel({
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], updatedAt: Date.now(), title: s.title || "当前会话" } : s,
s.id === activeSessionId
? {
...s,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
updatedAt: Date.now(),
title: s.title || "当前会话",
codexSessionId: null,
codexMode: null,
}
: s,
),
),
);
@@ -431,11 +472,17 @@ export function DocumentAiAgentPanel({
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
if (aiProvider === "codex") {
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
}
};
const send = async () => {
const content = input.trim();
if (!content) return;
const hasExplicitCodexMode = /^\s*#(chat|test|dev)\b/i.test(content);
const intendedCodexMode: CodexMode =
aiProvider === "codex" ? (hasExplicitCodexMode ? extractCodexMode(content) : "chat") : "chat";
setToolLogs([]);
if (activeSessionId && currentSessionTitle === "新会话") {
const title = content.length > 18 ? `${content.slice(0, 18)}` : content;
@@ -460,10 +507,25 @@ export function DocumentAiAgentPanel({
(m, idx) => !(idx === 0 && m.role === "assistant" && /页面 AI Agent/.test(m.content)),
);
const payloadMessagesForRequest = payloadMessages;
const blocks = getLatestBlocks();
const blocksJson = blocks ? safeJsonStringify(blocks) : "";
const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000;
const codexSessionIdForRequest =
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
if (aiProvider === "codex" && activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
),
),
);
}
try {
const res = await fetch("/api/ai-agent/run", {
method: "POST",
@@ -473,7 +535,7 @@ export function DocumentAiAgentPanel({
stream: true,
maxSteps,
scope: "document",
messages: payloadMessages.slice(-24),
messages: payloadMessagesForRequest.slice(-24),
toolChoice: toolAuto
? {
mode: "auto",
@@ -489,7 +551,14 @@ export function DocumentAiAgentPanel({
}
: { mode: "manual", tools: selectedTools },
context: { documentId, documentBlocks: shouldSendBlocks ? blocks : null },
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
});
if (!res.ok) {
@@ -499,6 +568,25 @@ export function DocumentAiAgentPanel({
}
await parseSseChunks(res, (event, dataText) => {
if (event === "codex_session") {
try {
const data = JSON.parse(dataText || "null") as unknown;
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
const sessionId = String(obj.sessionId ?? "").trim();
if (sessionId && activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexSessionId: sessionId, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
),
),
);
}
} catch {
// ignore
}
return;
}
if (event === "tool_call") {
try {
const data = JSON.parse(dataText || "null") as unknown;
@@ -564,6 +652,7 @@ export function DocumentAiAgentPanel({
}
if (event === "error") {
if (controller.signal.aborted && aiProvider === "codex") return;
try {
const data = JSON.parse(dataText || "null") as unknown;
const message =
@@ -576,6 +665,7 @@ export function DocumentAiAgentPanel({
}
});
} catch (e) {
if (controller.signal.aborted && aiProvider === "codex") return;
const msg = e instanceof Error ? e.message : String(e);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
} finally {
@@ -711,18 +801,24 @@ export function DocumentAiAgentPanel({
<select
className="h-8 rounded border bg-white px-2 text-xs"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
<input
className="h-8 w-[180px] rounded border px-2 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="model(可选)"
disabled={loading}
placeholder={aiProvider === "codex" ? "Codex 无需 model" : aiProvider === "ollama" ? `默认:${OLLAMA_QWEN3_30B}` : "model(可选)"}
disabled={loading || aiProvider === "codex"}
/>
</div>
</div>
@@ -782,6 +878,13 @@ export function DocumentAiAgentPanel({
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<details key={idx} className="rounded border p-2">
@@ -829,7 +932,7 @@ export function DocumentAiAgentPanel({
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
<X className="mr-2 h-4 w-4" />
{aiProvider === "codex" ? "暂停" : "停止"}
</Button>
</div>
</div>
@@ -959,23 +1062,123 @@ export function DocumentAiAgentPanel({
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
<label className="ml-2 text-xs text-muted-foreground"></label>
<input
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
{aiProvider === "codex" ? (
<div className="ml-2 space-y-2 text-xs text-muted-foreground">
<div>
使 Codex <code className="rounded bg-muted px-1 py-0.5">~/.codex/config.toml</code>
<code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span>Codex Session</span>
<input
className="h-8 w-[360px] rounded border bg-white px-2 text-xs"
value={codexSessionDraft}
onChange={(e) => setCodexSessionDraft(e.target.value)}
placeholder="留空=本会话自动创建;也可粘贴 VSCode/Codex CLI 的 thread_id 续聊"
disabled={loading}
/>
<Button
type="button"
size="sm"
variant="secondary"
disabled={loading || !activeSessionId}
onClick={() => {
const nextId = codexSessionDraft.trim() || null;
if (!activeSessionId) return;
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexSessionId: nextId, updatedAt: Date.now() } : s,
),
),
);
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={loading || !activeSessionId}
onClick={() => {
if (!activeSessionId) return;
setCodexSessionDraft("");
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexSessionId: null, updatedAt: Date.now() } : s,
),
),
);
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={loading || !String(currentSession?.codexSessionId ?? "").trim()}
onClick={() => {
const sid = String(currentSession?.codexSessionId ?? "").trim();
if (!sid) return;
void navigator.clipboard?.writeText(sid).catch(() => null);
}}
>
</Button>
</div>
<div>
VSCode Codex CLI <code className="rounded bg-muted px-1 py-0.5">#dev</code> SessionId
</div>
</div>
) : (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
{aiProvider === "ollama" ? (
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
list="doc-ai-model-suggestions"
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
)}
<datalist id="doc-ai-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</>
)}
</div>
<div className="text-xs text-muted-foreground">
线/ BaseURL Key / provider model
线/ BaseURL Key / provider model Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
</div>
</div>
</ScrollArea>
@@ -1147,23 +1350,56 @@ export function DocumentAiAgentPanel({
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
<label className="ml-2 text-xs text-muted-foreground"></label>
<input
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
{aiProvider === "codex" ? (
<div className="ml-2 text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
) : (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
{aiProvider === "ollama" ? (
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
list="doc-ai-model-suggestions-dialog"
className="h-9 w-[260px] rounded border px-2 text-sm"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="例如 gemini-2.5-pro"
disabled={loading}
/>
)}
<datalist id="doc-ai-model-suggestions-dialog">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</>
)}
</div>
<div className="text-xs text-muted-foreground">
线/ BaseURL Key / provider model
线/ BaseURL Key / provider model Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
</div>
</div>
</DialogContent>
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,18 @@
"use client";
import {
useEffect,
useRef,
useState,
useMemo,
useCallback,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
"use client";
import {
useEffect,
useRef,
useState,
useMemo,
useCallback,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
@@ -25,59 +25,59 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema> & { props: any };
editor: BlockNoteEditor<CustomBlockSchema>;
};
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
image: "图片",
video: "视频",
audio: "音频",
file: "文件",
};
const deriveFileName = (value?: string) => {
if (!value) {
return "未命名资源";
}
try {
const url = new URL(value);
const last = url.pathname.split("/").filter(Boolean).pop();
if (last) {
return decodeURIComponent(last);
}
} catch {
const segments = value.split("?")[0]?.split("/") ?? [];
const last = segments.pop();
if (last) {
return decodeURIComponent(last);
}
}
return "未命名资源";
};
const formatFileSize = (size?: number | null) => {
if (!size || size <= 0) {
return "未知大小";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let idx = 0;
let current = size;
while (current >= 1024 && idx < units.length - 1) {
current /= 1024;
idx += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema> & { props: any };
editor: BlockNoteEditor<CustomBlockSchema>;
};
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
image: "图片",
video: "视频",
audio: "音频",
file: "文件",
};
const deriveFileName = (value?: string) => {
if (!value) {
return "未命名资源";
}
try {
const url = new URL(value);
const last = url.pathname.split("/").filter(Boolean).pop();
if (last) {
return decodeURIComponent(last);
}
} catch {
const segments = value.split("?")[0]?.split("/") ?? [];
const last = segments.pop();
if (last) {
return decodeURIComponent(last);
}
}
return "未命名资源";
};
const formatFileSize = (size?: number | null) => {
if (!size || size <= 0) {
return "未知大小";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let idx = 0;
let current = size;
while (current >= 1024 && idx < units.length - 1) {
current /= 1024;
idx += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
const MediaBlockContent = ({ block, editor }: any) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
@@ -93,144 +93,144 @@ const MediaBlockContent = ({ block, editor }: any) => {
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
? (rawAssetType as MediaKind)
: "image";
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
const canAlign = assetType === "image" || assetType === "video";
const canToggleBorder = assetType === "image";
const canTriggerOcr = assetType === "image";
const canResize = assetType === "image" || assetType === "video";
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
const mediaRef = useRef<HTMLDivElement | null>(null);
const latestWidthRef = useRef(localWidth);
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
if (tail) {
const id = tail.split(/[/?#]/)[0];
if (id) return id;
}
}
return (block.props as { documentId?: string })?.documentId || "";
}, [block.props]);
const extension = useMemo(() => {
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
const match = /\.([a-z0-9]+)$/.exec(name);
return match?.[1] ?? "";
}, [block.props.fileName, fileUrl]);
const isOfficeDoc = useMemo(
() =>
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
extension,
),
[extension],
);
const handleChoose = () => {
openPicker({
defaultTab: fileUrl ? "recent" : "upload",
mediaType: assetType,
onSelect: (selection) => {
editor.updateBlock(block, {
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
documentId: resolveDocumentId(),
},
});
},
});
};
const toggleBorder = () => {
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
};
const setAlign = (align: MediaAlign) => {
editor.updateBlock(block, { props: { captionAlign: align } });
};
const handleCaptionChange = (value: string) => {
editor.updateBlock(block, { props: { caption: value } });
};
const enableCaptionEdit = () => {
setCaptionEditing(true);
setTimeout(() => captionRef.current?.focus(), 0);
};
useEffect(() => {
if (!shouldShowCaption && captionEditing) {
setCaptionEditing(false);
}
}, [captionEditing, shouldShowCaption]);
useEffect(() => {
if (!dragging) {
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
}
}, [block.props.width, dragging]);
useEffect(() => {
latestWidthRef.current = localWidth;
}, [localWidth]);
const resolvedWidth = useMemo(() => {
if (!canResize) return 0;
if (localWidth > 0) return clampWidth(localWidth);
if (block.props.width && Number(block.props.width) > 0) {
return clampWidth(Number(block.props.width));
}
return 0;
}, [block.props.width, canResize, localWidth]);
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
if (!canResize) return;
event.preventDefault();
event.stopPropagation();
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
if (!canvasWidth) {
return;
}
setDragging({
side,
startX: event.clientX,
startWidth: canvasWidth,
});
};
useEffect(() => {
if (!dragging) {
return undefined;
}
const handleMove = (event: MouseEvent) => {
event.preventDefault();
const delta = event.clientX - dragging.startX;
const adjusted = dragging.side === "left" ? -delta : delta;
const next = clampWidth(dragging.startWidth + adjusted);
setLocalWidth(next);
};
const handleUp = () => {
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
setDragging(null);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, editor, block]);
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
const canAlign = assetType === "image" || assetType === "video";
const canToggleBorder = assetType === "image";
const canTriggerOcr = assetType === "image";
const canResize = assetType === "image" || assetType === "video";
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
const mediaRef = useRef<HTMLDivElement | null>(null);
const latestWidthRef = useRef(localWidth);
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
if (tail) {
const id = tail.split(/[/?#]/)[0];
if (id) return id;
}
}
return (block.props as { documentId?: string })?.documentId || "";
}, [block.props]);
const extension = useMemo(() => {
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
const match = /\.([a-z0-9]+)$/.exec(name);
return match?.[1] ?? "";
}, [block.props.fileName, fileUrl]);
const isOfficeDoc = useMemo(
() =>
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
extension,
),
[extension],
);
const handleChoose = () => {
openPicker({
defaultTab: fileUrl ? "recent" : "upload",
mediaType: assetType,
onSelect: (selection) => {
editor.updateBlock(block, {
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
documentId: resolveDocumentId(),
},
});
},
});
};
const toggleBorder = () => {
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
};
const setAlign = (align: MediaAlign) => {
editor.updateBlock(block, { props: { captionAlign: align } });
};
const handleCaptionChange = (value: string) => {
editor.updateBlock(block, { props: { caption: value } });
};
const enableCaptionEdit = () => {
setCaptionEditing(true);
setTimeout(() => captionRef.current?.focus(), 0);
};
useEffect(() => {
if (!shouldShowCaption && captionEditing) {
setCaptionEditing(false);
}
}, [captionEditing, shouldShowCaption]);
useEffect(() => {
if (!dragging) {
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
}
}, [block.props.width, dragging]);
useEffect(() => {
latestWidthRef.current = localWidth;
}, [localWidth]);
const resolvedWidth = useMemo(() => {
if (!canResize) return 0;
if (localWidth > 0) return clampWidth(localWidth);
if (block.props.width && Number(block.props.width) > 0) {
return clampWidth(Number(block.props.width));
}
return 0;
}, [block.props.width, canResize, localWidth]);
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
if (!canResize) return;
event.preventDefault();
event.stopPropagation();
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
if (!canvasWidth) {
return;
}
setDragging({
side,
startX: event.clientX,
startWidth: canvasWidth,
});
};
useEffect(() => {
if (!dragging) {
return undefined;
}
const handleMove = (event: MouseEvent) => {
event.preventDefault();
const delta = event.clientX - dragging.startX;
const adjusted = dragging.side === "left" ? -delta : delta;
const next = clampWidth(dragging.startWidth + adjusted);
setLocalWidth(next);
};
const handleUp = () => {
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
setDragging(null);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, editor, block]);
const handleLink = () => {
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
if (next === null) return;
@@ -282,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const openWithOnlyOffice = async () => {
if (!fileUrl) return;
if (!officeBase) {
@@ -342,65 +342,65 @@ const MediaBlockContent = ({ block, editor }: any) => {
anchor.download = block.props.fileName || block.props.caption || typeLabel;
anchor.click();
};
const handleDeleteAsset = async () => {
const assetId = (block.props as { assetId?: string })?.assetId;
if (!assetId) {
editor.removeBlocks([block.id]);
return;
}
const docId = resolveDocumentId();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
editor.removeBlocks([block.id]);
emitAssetsChanged(docId);
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
return;
}
setBusy(true);
try {
const response = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: block.props.assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "触发 OCR 失败");
}
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
} catch (error) {
window.alert((error as Error).message);
} finally {
setBusy(false);
}
};
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
<ImageIcon className="h-4 w-4" />
{typeLabel}
</Button>
<p className="text-xs text-gray-500"></p>
</div>
);
}
const handleDeleteAsset = async () => {
const assetId = (block.props as { assetId?: string })?.assetId;
if (!assetId) {
editor.removeBlocks([block.id]);
return;
}
const docId = resolveDocumentId();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
editor.removeBlocks([block.id]);
emitAssetsChanged(docId);
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
return;
}
setBusy(true);
try {
const response = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: block.props.assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "触发 OCR 失败");
}
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
} catch (error) {
window.alert((error as Error).message);
} finally {
setBusy(false);
}
};
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
<ImageIcon className="h-4 w-4" />
{typeLabel}
</Button>
<p className="text-xs text-gray-500"></p>
</div>
);
}
const renderPreviewContent = () => {
if (assetType === "video") {
return (
@@ -424,17 +424,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
</div>
);
}
if (assetType === "file") {
// 根据文件扩展名确定图标颜色
const getIconColor = () => {
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
if (ext === "pdf") return "text-red-500";
if (["doc", "docx"].includes(ext)) return "text-blue-600";
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
return "text-[#9B9A97]";
};
if (assetType === "file") {
// 根据文件扩展名确定图标颜色
const getIconColor = () => {
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
if (ext === "pdf") return "text-red-500";
if (["doc", "docx"].includes(ext)) return "text-blue-600";
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
return "text-[#9B9A97]";
};
return (
<div
role="button"
@@ -548,62 +548,62 @@ const MediaBlockContent = ({ block, editor }: any) => {
/>
);
};
const figure = (
<figure
className={cn(
"wolai-media__figure",
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
)}
>
<div className="wolai-media__preview">{renderPreviewContent()}</div>
{shouldShowCaption && (
<figcaption>
<input
ref={captionRef}
value={block.props.caption ?? ""}
onChange={(event) => handleCaptionChange(event.target.value)}
onBlur={() => setCaptionEditing(false)}
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
/>
</figcaption>
)}
</figure>
);
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
const quickActions: QuickAction[] = [
{
key: "replace",
label: `替换${typeLabel}`,
icon: <RefreshCcw className="h-4 w-4" />,
onClick: handleChoose,
},
canToggleBorder
? {
key: "border",
label: block.props.hasBorder ? "取消边框" : "显示边框",
icon: <ImageIcon className="h-4 w-4" />,
onClick: toggleBorder,
}
: null,
!shouldShowCaption
? {
key: "caption",
label: "添加说明",
icon: <Type className="h-4 w-4" />,
onClick: enableCaptionEdit,
}
: null,
{
key: "link",
label: block.props.linkUrl ? "编辑链接" : "添加链接",
icon: <LinkIcon className="h-4 w-4" />,
onClick: handleLink,
},
const figure = (
<figure
className={cn(
"wolai-media__figure",
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
)}
>
<div className="wolai-media__preview">{renderPreviewContent()}</div>
{shouldShowCaption && (
<figcaption>
<input
ref={captionRef}
value={block.props.caption ?? ""}
onChange={(event) => handleCaptionChange(event.target.value)}
onBlur={() => setCaptionEditing(false)}
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
/>
</figcaption>
)}
</figure>
);
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
const quickActions: QuickAction[] = [
{
key: "replace",
label: `替换${typeLabel}`,
icon: <RefreshCcw className="h-4 w-4" />,
onClick: handleChoose,
},
canToggleBorder
? {
key: "border",
label: block.props.hasBorder ? "取消边框" : "显示边框",
icon: <ImageIcon className="h-4 w-4" />,
onClick: toggleBorder,
}
: null,
!shouldShowCaption
? {
key: "caption",
label: "添加说明",
icon: <Type className="h-4 w-4" />,
onClick: enableCaptionEdit,
}
: null,
{
key: "link",
label: block.props.linkUrl ? "编辑链接" : "添加链接",
icon: <LinkIcon className="h-4 w-4" />,
onClick: handleLink,
},
!downloadDisabled
? {
key: "download",
@@ -614,16 +614,16 @@ const MediaBlockContent = ({ block, editor }: any) => {
},
}
: null,
{
key: "delete",
label: `删除${typeLabel}`,
icon: <Trash className="h-4 w-4" />,
onClick: handleDeleteAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
{
key: "delete",
label: `删除${typeLabel}`,
icon: <Trash className="h-4 w-4" />,
onClick: handleDeleteAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
return (
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
<div
@@ -636,11 +636,11 @@ const MediaBlockContent = ({ block, editor }: any) => {
void viewOriginal();
}
}}
>
{block.props.linkUrl ? (
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
{figure}
</a>
>
{block.props.linkUrl ? (
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
{figure}
</a>
) : (
figure
)}
@@ -651,38 +651,38 @@ const MediaBlockContent = ({ block, editor }: any) => {
key={action.key}
type="button"
className="wolai-media__quickbutton"
onClick={action.onClick}
title={action.label}
aria-label={action.label}
>
{action.icon}
</button>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && (
<DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>
)}
{canToggleBorder && (
<DropdownMenuItem onClick={toggleBorder}>
{block.props.hasBorder ? "取消边框" : "显示边框"}
</DropdownMenuItem>
)}
{canAlign && (
<>
<DropdownMenuLabel className="text-xs text-gray-400"></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setAlign("left")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("center")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("right")}></DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
onClick={action.onClick}
title={action.label}
aria-label={action.label}
>
{action.icon}
</button>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && (
<DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>
)}
{canToggleBorder && (
<DropdownMenuItem onClick={toggleBorder}>
{block.props.hasBorder ? "取消边框" : "显示边框"}
</DropdownMenuItem>
)}
{canAlign && (
<>
<DropdownMenuLabel className="text-xs text-gray-400"></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setAlign("left")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("center")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("right")}></DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem
@@ -700,14 +700,14 @@ const MediaBlockContent = ({ block, editor }: any) => {
>
{downloadDisabled ? "已禁止下载" : "下载到本地"}
</DropdownMenuItem>
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
</DropdownMenuItem>
</>
)}
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -717,73 +717,73 @@ const MediaBlockContent = ({ block, editor }: any) => {
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
</>
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
</div>
);
};
export const mediaBlock = createReactBlockSpec(
{
type: "media",
propSchema: {
fileUrl: { default: "", type: "string" },
thumbnailUrl: { default: "", type: "string" },
caption: { default: "", type: "string" },
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
hasBorder: { default: true, type: "boolean" },
linkUrl: { default: "", type: "string" },
assetId: { default: "", type: "string" },
assetType: { default: "image", type: "string" },
fileName: { default: "", type: "string" },
fileSize: { default: 0, type: "number" },
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
documentId: { default: "", type: "string" },
},
content: "none",
},
{
render: (props) => <MediaBlockContent {...props} />,
},
)();
const handleCopyLink = async (targetUrl: string | null) => {
if (!targetUrl) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetUrl);
window.alert("链接已复制");
} else {
throw new Error("no clipboard");
}
} catch {
window.prompt("请复制以下链接", targetUrl);
}
};
const ResizeHandle = ({
side,
onMouseDown,
dragging,
}: {
side: "left" | "right";
dragging: boolean;
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
}) => (
<span
role="separator"
tabIndex={0}
aria-orientation="horizontal"
onMouseDown={onMouseDown}
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
/>
);
const clampWidth = (value: number) => {
const min = 240;
const max = 960;
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
};
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
</div>
);
};
export const mediaBlock = createReactBlockSpec(
{
type: "media",
propSchema: {
fileUrl: { default: "", type: "string" },
thumbnailUrl: { default: "", type: "string" },
caption: { default: "", type: "string" },
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
hasBorder: { default: true, type: "boolean" },
linkUrl: { default: "", type: "string" },
assetId: { default: "", type: "string" },
assetType: { default: "image", type: "string" },
fileName: { default: "", type: "string" },
fileSize: { default: 0, type: "number" },
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
documentId: { default: "", type: "string" },
},
content: "none",
},
{
render: (props) => <MediaBlockContent {...props} />,
},
)();
const handleCopyLink = async (targetUrl: string | null) => {
if (!targetUrl) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetUrl);
window.alert("链接已复制");
} else {
throw new Error("no clipboard");
}
} catch {
window.prompt("请复制以下链接", targetUrl);
}
};
const ResizeHandle = ({
side,
onMouseDown,
dragging,
}: {
side: "left" | "right";
dragging: boolean;
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
}) => (
<span
role="separator"
tabIndex={0}
aria-orientation="horizontal"
onMouseDown={onMouseDown}
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
/>
);
const clampWidth = (value: number) => {
const min = 240;
const max = 960;
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
};
@@ -18,6 +18,19 @@ type AgentAssetItem = {
};
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const extractCodexMode = (text: string): CodexMode => {
const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b/i);
if (!m) return "chat";
const mode = String(m[1] ?? "").toLowerCase();
if (mode === "dev" || mode === "test" || mode === "chat") return mode;
return "chat";
};
type MindmapInstanceLike = {
setData?: (data: unknown) => void;
@@ -35,6 +48,7 @@ const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "info"; message: string }
| { type: "error"; message: string };
type ChatSession = {
@@ -45,6 +59,8 @@ type ChatSession = {
messages: AgentMessage[];
toolLogs: ToolLog[];
attachments: AgentAssetItem[];
codexSessionId?: string | null;
codexMode?: CodexMode | null;
};
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
@@ -169,7 +185,7 @@ export function MindmapAiAgentPanel({
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
const [page, setPage] = useState<PanelPage>("chat");
@@ -198,7 +214,7 @@ export function MindmapAiAgentPanel({
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
const m = window.localStorage.getItem("mindmap_ai_model") || "";
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
if (p === "local" || p === "online") setAiProvider(p);
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
const parsed = Number(stepsRaw);
if (Number.isFinite(parsed) && parsed >= 1) {
@@ -240,6 +256,8 @@ export function MindmapAiAgentPanel({
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
codexSessionId: null,
codexMode: null,
};
setSessions([session]);
setActiveSessionId(id);
@@ -267,7 +285,10 @@ export function MindmapAiAgentPanel({
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
const attachments = Array.isArray((x as any)?.attachments) ? (x as any).attachments : [];
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments } as ChatSession;
const codexSessionId = String((x as any)?.codexSessionId ?? "").trim() || null;
const codexModeRaw = String((x as any)?.codexMode ?? "").trim();
const codexMode = codexModeRaw === "chat" || codexModeRaw === "test" || codexModeRaw === "dev" ? (codexModeRaw as CodexMode) : null;
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments, codexSessionId, codexMode } as ChatSession;
})
.filter((s) => s.id),
);
@@ -303,6 +324,8 @@ export function MindmapAiAgentPanel({
messages,
toolLogs,
attachments,
codexSessionId: null,
codexMode: null,
},
...prev,
];
@@ -625,6 +648,8 @@ export function MindmapAiAgentPanel({
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
codexSessionId: null,
codexMode: null,
};
setSessions((prev) => normalizeSessions([next, ...prev]));
setActiveSessionId(id);
@@ -659,7 +684,16 @@ export function MindmapAiAgentPanel({
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId
? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], attachments: [], updatedAt: Date.now(), title: s.title || "当前会话" }
? {
...s,
messages: DEFAULT_SESSION_MESSAGES,
toolLogs: [],
attachments: [],
updatedAt: Date.now(),
title: s.title || "当前会话",
codexSessionId: null,
codexMode: null,
}
: s,
),
),
@@ -719,12 +753,18 @@ export function MindmapAiAgentPanel({
} finally {
abortRef.current = null;
setLoading(false);
if (aiProvider === "codex") {
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
}
}
};
const send = async () => {
const content = input.trim();
if (!content) return;
const hasExplicitCodexMode = /^\s*#(chat|test|dev)\b/i.test(content);
const intendedCodexMode: CodexMode =
aiProvider === "codex" ? (hasExplicitCodexMode ? extractCodexMode(content) : "chat") : "chat";
setDebug("");
setToolLogs([]);
if (activeSessionId && currentSessionTitle === "新会话") {
@@ -737,9 +777,10 @@ export function MindmapAiAgentPanel({
setInput("");
setLoading(true);
let controller: AbortController | null = null;
try {
abortRef.current?.abort();
const controller = new AbortController();
controller = new AbortController();
abortRef.current = controller;
// 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行
@@ -747,6 +788,21 @@ export function MindmapAiAgentPanel({
(m, idx) => !(idx === 0 && m.role === "assistant" && /思维导图 AI Agent/.test(m.content)),
);
const payloadMessagesForRequest = payloadMessages;
const codexSessionIdForRequest =
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
if (aiProvider === "codex" && activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
),
),
);
}
const res = await fetch("/api/ai-agent/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -755,7 +811,7 @@ export function MindmapAiAgentPanel({
stream: true,
maxSteps,
scope: "mindmap",
messages: payloadMessages.slice(-24),
messages: payloadMessagesForRequest.slice(-24),
toolChoice: toolAuto
? {
mode: "auto",
@@ -775,7 +831,14 @@ export function MindmapAiAgentPanel({
fileUrl: a.fileUrl,
mimeType: a.mimeType ?? null,
})),
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
});
if (!res.ok) {
@@ -788,6 +851,26 @@ export function MindmapAiAgentPanel({
await parseSseChunks(res, (event, dataText) => {
rawEvents.push({ event, dataText });
if (event === "codex_session") {
try {
const data = JSON.parse(dataText || "null") as unknown;
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
const sessionId = String(obj.sessionId ?? "").trim();
if (sessionId && activeSessionId) {
setSessions((prev) =>
normalizeSessions(
prev.map((s) =>
s.id === activeSessionId ? { ...s, codexSessionId: sessionId, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
),
),
);
}
} catch {
// ignore
}
return;
}
if (event === "tool_call") {
try {
const data = JSON.parse(dataText || "null") as unknown;
@@ -870,6 +953,7 @@ export function MindmapAiAgentPanel({
}
if (event === "error") {
if (controller?.signal.aborted && aiProvider === "codex") return;
try {
const data = JSON.parse(dataText || "null") as unknown;
const msg =
@@ -884,6 +968,7 @@ export function MindmapAiAgentPanel({
setDebug(JSON.stringify(rawEvents.slice(-120), null, 2));
} catch (e) {
if (controller?.signal.aborted && aiProvider === "codex") return;
const msg = e instanceof Error ? e.message : String(e);
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
@@ -977,6 +1062,13 @@ export function MindmapAiAgentPanel({
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<details key={idx} className="rounded border p-2">
@@ -1098,9 +1190,14 @@ export function MindmapAiAgentPanel({
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" disabled={!loading} onClick={stop} title="停止本次执行">
<Button
variant="secondary"
disabled={!loading}
onClick={stop}
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC" : "停止本次执行"}
>
<X className="mr-2 h-4 w-4" />
{aiProvider === "codex" ? "暂停" : "停止"}
</Button>
<Button disabled={!canSend} onClick={() => void send()}>
<Send className="mr-2 h-4 w-4" />
@@ -1222,18 +1319,30 @@ export function MindmapAiAgentPanel({
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
<label className="ml-2 text-xs text-muted-foreground"></label>
{aiProvider === "online" ? (
{aiProvider === "codex" ? (
<div className="text-xs text-muted-foreground">
使 Codex <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel}
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
@@ -1243,6 +1352,16 @@ export function MindmapAiAgentPanel({
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="h-9 rounded border bg-white px-2 text-sm"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
@@ -1251,6 +1370,7 @@ export function MindmapAiAgentPanel({
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
list="mindmap-local-model-suggestions"
/>
)}
</div>
@@ -1258,7 +1378,14 @@ export function MindmapAiAgentPanel({
<div className="text-xs text-muted-foreground">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : aiProvider === "ollama" ? (
<div className="text-xs text-muted-foreground">
Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>(可用 <code className="rounded bg-muted px-1 py-0.5">OLLAMA_BASE_URL</code> 覆盖)。
</div>
) : null}
<datalist id="mindmap-local-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
</ScrollArea>
) : null}
@@ -1438,13 +1565,37 @@ export function MindmapAiAgentPanel({
/>
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "ollama"}
onChange={() => setAiProvider("ollama")}
/>
Ollama
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "codex"}
onChange={() => setAiProvider("codex")}
/>
Codex
</label>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
{aiProvider === "online" ? (
{aiProvider === "codex" ? (
<div className="text-[11px] text-gray-500">
<code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code> <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
{ONLINE_MODELS.map((m) => (
@@ -1453,6 +1604,15 @@ export function MindmapAiAgentPanel({
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
@@ -1460,8 +1620,12 @@ export function MindmapAiAgentPanel({
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
list="mindmap-local-model-suggestions-bottom"
/>
)}
<datalist id="mindmap-local-model-suggestions-bottom">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
@@ -1572,10 +1736,10 @@ export function MindmapAiAgentPanel({
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
disabled={!loading}
onClick={() => abortRef.current?.abort()}
title="停止本次执行"
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC" : "停止本次执行"}
>
<X className="h-4 w-4" />
{aiProvider === "codex" ? "暂停" : "停止"}
</button>
<button
type="button"
@@ -1602,6 +1766,13 @@ export function MindmapAiAgentPanel({
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
File diff suppressed because it is too large Load Diff
@@ -1,171 +1,171 @@
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import type { MindMapNode } from "./mindmapTypes";
// 菜单项配置
interface ContextMenuItem {
key?: string;
label?: string;
shortcut?: string;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
show?: (node: MindMapNode | null) => boolean;
}
// 节点右键菜单配置
const NODE_MENU_ITEMS: ContextMenuItem[] = [
{
key: "INSERT_NODE",
label: "插入同级节点",
shortcut: "Enter",
},
{
key: "INSERT_CHILD_NODE",
label: "插入子级节点",
shortcut: "Tab",
},
{
key: "INSERT_PARENT_NODE",
label: "插入父节点",
shortcut: "Shift + Tab",
},
{
key: "ADD_GENERALIZATION",
label: "插入概要",
shortcut: "Ctrl + G",
},
{ divider: true },
{
key: "UP_NODE",
label: "上移节点",
shortcut: "Ctrl + ↑",
},
{
key: "DOWN_NODE",
label: "下移节点",
shortcut: "Ctrl + ↓",
},
{
key: "UNEXPAND_ALL",
label: "收起所有下级节点",
},
{
key: "EXPAND_ALL",
label: "展开所有下级节点",
},
{ divider: true },
{
key: "REMOVE_NODE",
label: "删除节点",
shortcut: "Delete",
danger: true,
},
{
key: "REMOVE_CURRENT_NODE",
label: "仅删除当前节点",
shortcut: "Shift + Backspace",
danger: true,
},
{ divider: true },
{
key: "COPY_NODE",
label: "复制节点",
shortcut: "Ctrl + C",
},
{
key: "CUT_NODE",
label: "剪切节点",
shortcut: "Ctrl + X",
},
{
key: "PASTE_NODE",
label: "粘贴节点",
shortcut: "Ctrl + V",
},
{ divider: true },
{
key: "REMOVE_HYPERLINK",
label: "移除超链接",
show: (node) => !!node?.getData?.("hyperlink"),
},
{
key: "REMOVE_NOTE",
label: "移除备注",
show: (node) => !!node?.getData?.("note"),
},
{
key: "REMOVE_CUSTOM_STYLES",
label: "一键去除自定义样式",
},
{
key: "EXPORT_CUR_NODE_TO_PNG",
label: "导出该节点为图片",
},
{ divider: true },
{
key: "AI_CONTINUE",
label: "AI续写",
},
];
interface MindmapContextMenuProps {
mindmap: any | null;
}
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
// 判断是否禁用某个菜单项
const isItemDisabled = useCallback(
(item: ContextMenuItem): boolean => {
if (!targetNode) return false;
const isRoot = (targetNode as any).isRoot === true;
const isGeneralization = (targetNode as any).isGeneralization === true;
switch (item.key) {
case "INSERT_NODE":
case "INSERT_PARENT_NODE":
case "ADD_GENERALIZATION":
return isRoot || isGeneralization;
case "INSERT_CHILD_NODE":
return isGeneralization;
case "COPY_NODE":
case "CUT_NODE":
return isGeneralization;
case "UP_NODE": {
if (isRoot || isGeneralization) return true;
const parent = (targetNode as any).parent;
if (!parent || !Array.isArray(parent.children)) return true;
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
}
case "DOWN_NODE": {
if (isRoot || isGeneralization) return true;
const parent = (targetNode as any).parent;
if (!parent || !Array.isArray(parent.children)) return true;
const children = parent.children;
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
}
default:
return false;
}
},
[targetNode]
);
// 过滤显示的菜单项
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import type { MindMapNode } from "./mindmapTypes";
// 菜单项配置
interface ContextMenuItem {
key?: string;
label?: string;
shortcut?: string;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
show?: (node: MindMapNode | null) => boolean;
}
// 节点右键菜单配置
const NODE_MENU_ITEMS: ContextMenuItem[] = [
{
key: "INSERT_NODE",
label: "插入同级节点",
shortcut: "Enter",
},
{
key: "INSERT_CHILD_NODE",
label: "插入子级节点",
shortcut: "Tab",
},
{
key: "INSERT_PARENT_NODE",
label: "插入父节点",
shortcut: "Shift + Tab",
},
{
key: "ADD_GENERALIZATION",
label: "插入概要",
shortcut: "Ctrl + G",
},
{ divider: true },
{
key: "UP_NODE",
label: "上移节点",
shortcut: "Ctrl + ↑",
},
{
key: "DOWN_NODE",
label: "下移节点",
shortcut: "Ctrl + ↓",
},
{
key: "UNEXPAND_ALL",
label: "收起所有下级节点",
},
{
key: "EXPAND_ALL",
label: "展开所有下级节点",
},
{ divider: true },
{
key: "REMOVE_NODE",
label: "删除节点",
shortcut: "Delete",
danger: true,
},
{
key: "REMOVE_CURRENT_NODE",
label: "仅删除当前节点",
shortcut: "Shift + Backspace",
danger: true,
},
{ divider: true },
{
key: "COPY_NODE",
label: "复制节点",
shortcut: "Ctrl + C",
},
{
key: "CUT_NODE",
label: "剪切节点",
shortcut: "Ctrl + X",
},
{
key: "PASTE_NODE",
label: "粘贴节点",
shortcut: "Ctrl + V",
},
{ divider: true },
{
key: "REMOVE_HYPERLINK",
label: "移除超链接",
show: (node) => !!node?.getData?.("hyperlink"),
},
{
key: "REMOVE_NOTE",
label: "移除备注",
show: (node) => !!node?.getData?.("note"),
},
{
key: "REMOVE_CUSTOM_STYLES",
label: "一键去除自定义样式",
},
{
key: "EXPORT_CUR_NODE_TO_PNG",
label: "导出该节点为图片",
},
{ divider: true },
{
key: "AI_CONTINUE",
label: "AI续写",
},
];
interface MindmapContextMenuProps {
mindmap: any | null;
}
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
// 判断是否禁用某个菜单项
const isItemDisabled = useCallback(
(item: ContextMenuItem): boolean => {
if (!targetNode) return false;
const isRoot = (targetNode as any).isRoot === true;
const isGeneralization = (targetNode as any).isGeneralization === true;
switch (item.key) {
case "INSERT_NODE":
case "INSERT_PARENT_NODE":
case "ADD_GENERALIZATION":
return isRoot || isGeneralization;
case "INSERT_CHILD_NODE":
return isGeneralization;
case "COPY_NODE":
case "CUT_NODE":
return isGeneralization;
case "UP_NODE": {
if (isRoot || isGeneralization) return true;
const parent = (targetNode as any).parent;
if (!parent || !Array.isArray(parent.children)) return true;
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
}
case "DOWN_NODE": {
if (isRoot || isGeneralization) return true;
const parent = (targetNode as any).parent;
if (!parent || !Array.isArray(parent.children)) return true;
const children = parent.children;
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
}
default:
return false;
}
},
[targetNode]
);
// 过滤显示的菜单项
const getVisibleItems = useCallback((): ContextMenuItem[] => {
return NODE_MENU_ITEMS.filter((item) => {
if (item.divider) return true;
@@ -186,57 +186,57 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
const executeCommand = useCallback(
(key: string) => {
if (!mindmap || !targetNode) return;
switch (key) {
case "COPY_NODE":
mindmap.renderer?.copy?.();
break;
case "CUT_NODE":
mindmap.renderer?.cut?.();
break;
case "PASTE_NODE":
mindmap.renderer?.paste?.();
break;
case "REMOVE_HYPERLINK":
if (typeof (targetNode as any).setHyperlink === "function") {
(targetNode as any).setHyperlink("", "");
}
break;
case "REMOVE_NOTE":
if (typeof (targetNode as any).setNote === "function") {
(targetNode as any).setNote("");
}
break;
case "EXPORT_CUR_NODE_TO_PNG": {
const getTextFromHtml = (html: string) => {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || div.innerText || "";
};
const nodeText = targetNode.getData?.("text") || "";
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
break;
}
case "UNEXPAND_ALL":
mindmap.execCommand?.(key, false, targetNode);
break;
case "EXPAND_ALL":
mindmap.execCommand?.(key, (targetNode as any).uid || "");
break;
case "AI_CONTINUE":
// 触发 AI 续写
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("mindmap-ai-continue", {
detail: { node: targetNode },
})
);
}
break;
default:
mindmap.execCommand?.(key);
break;
}
switch (key) {
case "COPY_NODE":
mindmap.renderer?.copy?.();
break;
case "CUT_NODE":
mindmap.renderer?.cut?.();
break;
case "PASTE_NODE":
mindmap.renderer?.paste?.();
break;
case "REMOVE_HYPERLINK":
if (typeof (targetNode as any).setHyperlink === "function") {
(targetNode as any).setHyperlink("", "");
}
break;
case "REMOVE_NOTE":
if (typeof (targetNode as any).setNote === "function") {
(targetNode as any).setNote("");
}
break;
case "EXPORT_CUR_NODE_TO_PNG": {
const getTextFromHtml = (html: string) => {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || div.innerText || "";
};
const nodeText = targetNode.getData?.("text") || "";
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
break;
}
case "UNEXPAND_ALL":
mindmap.execCommand?.(key, false, targetNode);
break;
case "EXPAND_ALL":
mindmap.execCommand?.(key, (targetNode as any).uid || "");
break;
case "AI_CONTINUE":
// 触发 AI 续写
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("mindmap-ai-continue", {
detail: { node: targetNode },
})
);
}
break;
default:
mindmap.execCommand?.(key);
break;
}
hide();
},
@@ -246,275 +246,275 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
const show = useCallback((x: number, y: number, node: MindMapNode) => {
setTargetNode(node);
// 计算可见菜单项数量
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
if (item.divider) return true;
if (item.show && !item.show(node)) return false;
return true;
});
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
const itemHeight = 40;
const dividerHeight = 10;
const estimatedHeight = visibleItems.reduce((acc, item) => {
return acc + (item.divider ? dividerHeight : itemHeight);
}, 0) + 16; // +16 是上下 padding
const menuWidth = 250;
const menuHeight = estimatedHeight + 20; // 额外的安全边距
// 初始位置:鼠标右侧下方
let posX = x + 10;
let posY = y + 10;
// 如果右侧空间不足,显示在左侧
if (posX + menuWidth > window.innerWidth) {
posX = x - menuWidth - 20;
}
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
if (posY + menuHeight > window.innerHeight) {
posY = window.innerHeight - menuHeight - 10;
}
// 确保不会超出左边界
if (posX < 10) {
posX = 10;
}
// 确保菜单顶部不会超出窗口
if (posY < 10) {
posY = 10;
}
setPosition({ x: posX, y: posY });
setVisible(true);
}, []);
// 监听右键事件
useEffect(() => {
if (!mindmap) return;
const handleContextMenu = (e: Event) => {
const mouseEvent = e as MouseEvent;
// 检查是否点击在节点上
const target = mouseEvent.target as HTMLElement | SVGElement;
// simple-mind-map 的节点结构
// 尝试多种选择器
const nodeSelectors = [
".smm-node", // 主节点容器
".smm-node-light", // 亮色主题节点
"g[role='node']", // 带 role 属性的 g 元素
"g.smooth-smooth", // 特定样式的 g 元素
];
let clickedNodeEl: Element | null = null;
for (const selector of nodeSelectors) {
clickedNodeEl = target.closest?.(selector) || null;
if (clickedNodeEl) break;
}
// 如果没找到节点选择器,尝试查找包含 text 的元素
if (!clickedNodeEl) {
const parent = target.parentElement;
if (parent) {
// 检查父元素是否包含文本内容
const textContainer = parent.querySelector("text");
if (textContainer) {
clickedNodeEl = parent;
}
}
}
if (!clickedNodeEl) return;
// 阻止默认右键菜单
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
// 获取当前激活的节点作为右键点击的节点
const renderer = mindmap.renderer;
if (!renderer) return;
// 使用 activeNodeList 或 lastActiveNodeList
const activeList = renderer.activeNodeList ?? [];
const lastActiveList = renderer.lastActiveNodeList ?? [];
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
if (node) {
show(mouseEvent.clientX, mouseEvent.clientY, node);
}
};
// 延迟查找容器,确保 DOM 已经渲染
const timer = setTimeout(() => {
const container = document.querySelector("[data-testid='mindmap-canvas']");
if (container) {
container.addEventListener("contextmenu", handleContextMenu, true);
}
}, 100);
return () => {
clearTimeout(timer);
const container = document.querySelector("[data-testid='mindmap-canvas']");
if (container) {
container.removeEventListener("contextmenu", handleContextMenu, true);
}
};
}, [mindmap, show]);
// 监听画布点击事件隐藏菜单
useEffect(() => {
if (!mindmap) return;
const hideMenu = () => {
hide();
};
mindmap.on?.("draw_click", hideMenu);
mindmap.on?.("node_click", hideMenu);
mindmap.on?.("expand_btn_click", hideMenu);
return () => {
mindmap.off?.("draw_click", hideMenu);
mindmap.off?.("node_click", hideMenu);
mindmap.off?.("expand_btn_click", hideMenu);
};
}, [mindmap, hide]);
// 点击外部隐藏菜单
useEffect(() => {
if (!visible) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
hide();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
hide();
}
};
const handleScroll = () => {
hide();
};
const handleResize = () => {
hide();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
document.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
document.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}, [visible, hide]);
// 渲染菜单
const renderMenu = () => {
const visibleItems = getVisibleItems();
return (
<div
ref={menuRef}
className="mindmap-contextmenu"
style={{
position: "fixed",
left: `${position.x}px`,
top: `${position.y}px`,
zIndex: 9999,
minWidth: "200px",
maxWidth: "280px",
background: "#ffffff",
borderRadius: "8px",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
padding: "8px 0",
fontSize: "14px",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
{visibleItems.map((item, index) => {
if (item.divider) {
return (
<div
key={`divider-${index}`}
style={{
height: "1px",
background: "#e5e7eb",
margin: "4px 12px",
}}
/>
);
}
const disabled = isItemDisabled(item);
return (
<div
key={item.key || `item-${index}`}
onClick={() => {
if (disabled) return;
if (!item.key) return;
executeCommand(item.key);
}}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 16px",
cursor: disabled ? "not-allowed" : "pointer",
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
background: "transparent",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.background = "#f3f4f6";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
{item.shortcut && (
<span
style={{
fontSize: "12px",
color: "#9ca3af",
marginLeft: "24px",
}}
>
{item.shortcut}
</span>
)}
</div>
);
})}
</div>
);
};
if (typeof document === "undefined" || !visible) {
return null;
}
return createPortal(renderMenu(), document.body);
}
// 计算可见菜单项数量
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
if (item.divider) return true;
if (item.show && !item.show(node)) return false;
return true;
});
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
const itemHeight = 40;
const dividerHeight = 10;
const estimatedHeight = visibleItems.reduce((acc, item) => {
return acc + (item.divider ? dividerHeight : itemHeight);
}, 0) + 16; // +16 是上下 padding
const menuWidth = 250;
const menuHeight = estimatedHeight + 20; // 额外的安全边距
// 初始位置:鼠标右侧下方
let posX = x + 10;
let posY = y + 10;
// 如果右侧空间不足,显示在左侧
if (posX + menuWidth > window.innerWidth) {
posX = x - menuWidth - 20;
}
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
if (posY + menuHeight > window.innerHeight) {
posY = window.innerHeight - menuHeight - 10;
}
// 确保不会超出左边界
if (posX < 10) {
posX = 10;
}
// 确保菜单顶部不会超出窗口
if (posY < 10) {
posY = 10;
}
setPosition({ x: posX, y: posY });
setVisible(true);
}, []);
// 监听右键事件
useEffect(() => {
if (!mindmap) return;
const handleContextMenu = (e: Event) => {
const mouseEvent = e as MouseEvent;
// 检查是否点击在节点上
const target = mouseEvent.target as HTMLElement | SVGElement;
// simple-mind-map 的节点结构
// 尝试多种选择器
const nodeSelectors = [
".smm-node", // 主节点容器
".smm-node-light", // 亮色主题节点
"g[role='node']", // 带 role 属性的 g 元素
"g.smooth-smooth", // 特定样式的 g 元素
];
let clickedNodeEl: Element | null = null;
for (const selector of nodeSelectors) {
clickedNodeEl = target.closest?.(selector) || null;
if (clickedNodeEl) break;
}
// 如果没找到节点选择器,尝试查找包含 text 的元素
if (!clickedNodeEl) {
const parent = target.parentElement;
if (parent) {
// 检查父元素是否包含文本内容
const textContainer = parent.querySelector("text");
if (textContainer) {
clickedNodeEl = parent;
}
}
}
if (!clickedNodeEl) return;
// 阻止默认右键菜单
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
// 获取当前激活的节点作为右键点击的节点
const renderer = mindmap.renderer;
if (!renderer) return;
// 使用 activeNodeList 或 lastActiveNodeList
const activeList = renderer.activeNodeList ?? [];
const lastActiveList = renderer.lastActiveNodeList ?? [];
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
if (node) {
show(mouseEvent.clientX, mouseEvent.clientY, node);
}
};
// 延迟查找容器,确保 DOM 已经渲染
const timer = setTimeout(() => {
const container = document.querySelector("[data-testid='mindmap-canvas']");
if (container) {
container.addEventListener("contextmenu", handleContextMenu, true);
}
}, 100);
return () => {
clearTimeout(timer);
const container = document.querySelector("[data-testid='mindmap-canvas']");
if (container) {
container.removeEventListener("contextmenu", handleContextMenu, true);
}
};
}, [mindmap, show]);
// 监听画布点击事件隐藏菜单
useEffect(() => {
if (!mindmap) return;
const hideMenu = () => {
hide();
};
mindmap.on?.("draw_click", hideMenu);
mindmap.on?.("node_click", hideMenu);
mindmap.on?.("expand_btn_click", hideMenu);
return () => {
mindmap.off?.("draw_click", hideMenu);
mindmap.off?.("node_click", hideMenu);
mindmap.off?.("expand_btn_click", hideMenu);
};
}, [mindmap, hide]);
// 点击外部隐藏菜单
useEffect(() => {
if (!visible) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
hide();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
hide();
}
};
const handleScroll = () => {
hide();
};
const handleResize = () => {
hide();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
document.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
document.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}, [visible, hide]);
// 渲染菜单
const renderMenu = () => {
const visibleItems = getVisibleItems();
return (
<div
ref={menuRef}
className="mindmap-contextmenu"
style={{
position: "fixed",
left: `${position.x}px`,
top: `${position.y}px`,
zIndex: 9999,
minWidth: "200px",
maxWidth: "280px",
background: "#ffffff",
borderRadius: "8px",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
padding: "8px 0",
fontSize: "14px",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
{visibleItems.map((item, index) => {
if (item.divider) {
return (
<div
key={`divider-${index}`}
style={{
height: "1px",
background: "#e5e7eb",
margin: "4px 12px",
}}
/>
);
}
const disabled = isItemDisabled(item);
return (
<div
key={item.key || `item-${index}`}
onClick={() => {
if (disabled) return;
if (!item.key) return;
executeCommand(item.key);
}}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 16px",
cursor: disabled ? "not-allowed" : "pointer",
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
background: "transparent",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.background = "#f3f4f6";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
{item.shortcut && (
<span
style={{
fontSize: "12px",
color: "#9ca3af",
marginLeft: "24px",
}}
>
{item.shortcut}
</span>
)}
</div>
);
})}
</div>
);
};
if (typeof document === "undefined" || !visible) {
return null;
}
return createPortal(renderMenu(), document.body);
}
File diff suppressed because it is too large Load Diff
@@ -1,211 +1,211 @@
import React from "react";
import {
fileToolbarMeta,
fileToolbarOrder,
nodeToolbarMeta,
nodeToolbarOrder,
type FileToolbarKey,
type NodeToolbarKey,
} from "./mindmapToolbarConfig";
const stopEditorEvent = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
type ToolbarProps = {
canBack: boolean;
canForward: boolean;
painterMode: boolean;
onUndo: () => void;
onRedo: () => void;
onPainter: () => void;
onSibling: () => void;
onChild: () => void;
onDelete: () => void;
onImage: () => void;
onIcon: () => void;
onLink: () => void;
onNote: () => void;
onTag: () => void;
onSummary: () => void;
onAssociativeLine: () => void;
onFormula: () => void;
onAttachment: () => void;
onOuterFrame: () => void;
onAnnotation?: () => void;
onAi: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNew: () => void;
onOpenDirectory: () => void;
onSaveAs: () => void;
onDeleteMindmap: () => void;
onExportJson: () => void;
onExportPng: () => void;
onExportSvg: () => void;
onExportPdf: () => void;
onExportMd: () => void;
onExportTxt: () => void;
onExportXmind: () => void;
import React from "react";
import {
fileToolbarMeta,
fileToolbarOrder,
nodeToolbarMeta,
nodeToolbarOrder,
type FileToolbarKey,
type NodeToolbarKey,
} from "./mindmapToolbarConfig";
const stopEditorEvent = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
type ToolbarProps = {
canBack: boolean;
canForward: boolean;
painterMode: boolean;
onUndo: () => void;
onRedo: () => void;
onPainter: () => void;
onSibling: () => void;
onChild: () => void;
onDelete: () => void;
onImage: () => void;
onIcon: () => void;
onLink: () => void;
onNote: () => void;
onTag: () => void;
onSummary: () => void;
onAssociativeLine: () => void;
onFormula: () => void;
onAttachment: () => void;
onOuterFrame: () => void;
onAnnotation?: () => void;
onAi: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNew: () => void;
onOpenDirectory: () => void;
onSaveAs: () => void;
onDeleteMindmap: () => void;
onExportJson: () => void;
onExportPng: () => void;
onExportSvg: () => void;
onExportPdf: () => void;
onExportMd: () => void;
onExportTxt: () => void;
onExportXmind: () => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
};
const ToolbarButton = ({
iconClass,
label,
onClick,
disabled = false,
active = false,
className = "",
}: {
iconClass: string;
label: string;
onClick?: () => void;
disabled?: boolean;
active?: boolean;
className?: string;
}) => (
<button
type="button"
contentEditable={false}
onPointerDown={stopEditorEvent}
onMouseDown={stopEditorEvent}
onClick={(e) => {
stopEditorEvent(e);
onClick?.();
}}
disabled={disabled}
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
} ${className}`}
title={label}
>
<div
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
}`}
>
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
</div>
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
{label}
</span>
</button>
);
export const MindmapToolbar = ({
canBack,
canForward,
painterMode,
onUndo,
onRedo,
onPainter,
onSibling,
onChild,
onDelete,
onImage,
onIcon,
onLink,
onNote,
onTag,
onSummary,
onAssociativeLine,
onFormula,
onAttachment,
onOuterFrame,
onAnnotation,
onAi,
onImport,
onNew,
onOpenDirectory,
onSaveAs,
onDeleteMindmap,
onExportJson,
onExportPng,
onExportSvg,
onExportPdf,
onExportMd,
onExportTxt,
onExportXmind,
fileInputRef,
}: ToolbarProps) => {
const [showExport, setShowExport] = React.useState(false);
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
back: onUndo,
forward: onRedo,
painter: onPainter,
siblingNode: onSibling,
childNode: onChild,
deleteNode: onDelete,
image: onImage,
icon: onIcon,
link: onLink,
note: onNote,
tag: onTag,
summary: onSummary,
associativeLine: onAssociativeLine,
formula: onFormula,
attachment: onAttachment,
outerFrame: onOuterFrame,
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
ai: onAi,
};
const fileHandlers: Record<FileToolbarKey, () => void> = {
directory: onOpenDirectory,
newFile: onNew,
openFile: () => fileInputRef.current?.click(),
import: () => fileInputRef.current?.click(),
saveAs: onSaveAs,
deleteFile: onDeleteMindmap,
exportMenu: () => setShowExport((v) => !v),
};
const getNodeDisabled = (key: NodeToolbarKey) => {
if (key === "back") return !canBack;
if (key === "forward") return !canForward;
return false;
};
return (
<div
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
contentEditable={false}
onPointerDownCapture={(e) => e.stopPropagation()}
onMouseDownCapture={(e) => e.stopPropagation()}
>
{/* Left Section: Edit & Node Operations */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
{nodeToolbarOrder.map((key) => {
const meta = nodeToolbarMeta[key];
const onClick = nodeHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
disabled={getNodeDisabled(key)}
active={key === "painter" ? painterMode : false}
/>
);
})}
</div>
{/* Right Section: File & Export Actions */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
{fileToolbarOrder.map((key) => {
const meta = fileToolbarMeta[key];
const onClick = fileHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
/>
);
})}
const ToolbarButton = ({
iconClass,
label,
onClick,
disabled = false,
active = false,
className = "",
}: {
iconClass: string;
label: string;
onClick?: () => void;
disabled?: boolean;
active?: boolean;
className?: string;
}) => (
<button
type="button"
contentEditable={false}
onPointerDown={stopEditorEvent}
onMouseDown={stopEditorEvent}
onClick={(e) => {
stopEditorEvent(e);
onClick?.();
}}
disabled={disabled}
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
} ${className}`}
title={label}
>
<div
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
}`}
>
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
</div>
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
{label}
</span>
</button>
);
export const MindmapToolbar = ({
canBack,
canForward,
painterMode,
onUndo,
onRedo,
onPainter,
onSibling,
onChild,
onDelete,
onImage,
onIcon,
onLink,
onNote,
onTag,
onSummary,
onAssociativeLine,
onFormula,
onAttachment,
onOuterFrame,
onAnnotation,
onAi,
onImport,
onNew,
onOpenDirectory,
onSaveAs,
onDeleteMindmap,
onExportJson,
onExportPng,
onExportSvg,
onExportPdf,
onExportMd,
onExportTxt,
onExportXmind,
fileInputRef,
}: ToolbarProps) => {
const [showExport, setShowExport] = React.useState(false);
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
back: onUndo,
forward: onRedo,
painter: onPainter,
siblingNode: onSibling,
childNode: onChild,
deleteNode: onDelete,
image: onImage,
icon: onIcon,
link: onLink,
note: onNote,
tag: onTag,
summary: onSummary,
associativeLine: onAssociativeLine,
formula: onFormula,
attachment: onAttachment,
outerFrame: onOuterFrame,
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
ai: onAi,
};
const fileHandlers: Record<FileToolbarKey, () => void> = {
directory: onOpenDirectory,
newFile: onNew,
openFile: () => fileInputRef.current?.click(),
import: () => fileInputRef.current?.click(),
saveAs: onSaveAs,
deleteFile: onDeleteMindmap,
exportMenu: () => setShowExport((v) => !v),
};
const getNodeDisabled = (key: NodeToolbarKey) => {
if (key === "back") return !canBack;
if (key === "forward") return !canForward;
return false;
};
return (
<div
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
contentEditable={false}
onPointerDownCapture={(e) => e.stopPropagation()}
onMouseDownCapture={(e) => e.stopPropagation()}
>
{/* Left Section: Edit & Node Operations */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
{nodeToolbarOrder.map((key) => {
const meta = nodeToolbarMeta[key];
const onClick = nodeHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
disabled={getNodeDisabled(key)}
active={key === "painter" ? painterMode : false}
/>
);
})}
</div>
{/* Right Section: File & Export Actions */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
{fileToolbarOrder.map((key) => {
const meta = fileToolbarMeta[key];
const onClick = fileHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
/>
);
})}
<input
ref={fileInputRef}
type="file"
@@ -213,37 +213,37 @@ export const MindmapToolbar = ({
className="hidden"
onChange={onImport}
/>
{showExport ? (
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
{[
{ label: "JSON", onClick: onExportJson },
{ label: "PNG", onClick: onExportPng },
{ label: "SVG", onClick: onExportSvg },
{ label: "PDF", onClick: onExportPdf },
{ label: "Markdown", onClick: onExportMd },
{ label: "TXT", onClick: onExportTxt },
{ label: "XMind", onClick: onExportXmind },
].map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
contentEditable={false}
onPointerDown={stopEditorEvent}
onMouseDown={stopEditorEvent}
onClick={(e) => {
stopEditorEvent(e);
setShowExport(false);
item.onClick();
}}
>
<span>{item.label}</span>
<i className="iconfont iconexport text-[12px]" />
</button>
))}
</div>
) : null}
</div>
</div>
);
};
{showExport ? (
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
{[
{ label: "JSON", onClick: onExportJson },
{ label: "PNG", onClick: onExportPng },
{ label: "SVG", onClick: onExportSvg },
{ label: "PDF", onClick: onExportPdf },
{ label: "Markdown", onClick: onExportMd },
{ label: "TXT", onClick: onExportTxt },
{ label: "XMind", onClick: onExportXmind },
].map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
contentEditable={false}
onPointerDown={stopEditorEvent}
onMouseDown={stopEditorEvent}
onClick={(e) => {
stopEditorEvent(e);
setShowExport(false);
item.onClick();
}}
>
<span>{item.label}</span>
<i className="iconfont iconexport text-[12px]" />
</button>
))}
</div>
) : null}
</div>
</div>
);
};
@@ -1,50 +1,50 @@
"use client";
"use client";
import { BlockNoteEditor, Block } from "@blocknote/core";
import { createReactBlockSpec } from "@blocknote/react";
import React, { useCallback, useMemo, useState } from "react";
import type { CustomBlockSchema } from "../schema";
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
import { useEditorBridgeStore } from "@/store/editor-bridge";
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 420;
const MAX_WIDTH = 1400;
const MIN_HEIGHT = 320;
const MAX_HEIGHT = 900;
type ResizeHandle =
| "left"
| "right"
| "top"
| "bottom"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
const handleMapping: Record<
ResizeHandle,
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
> = {
left: { horizontal: "left" },
right: { horizontal: "right" },
top: { vertical: "top" },
bottom: { vertical: "bottom" },
"top-left": { horizontal: "left", vertical: "top" },
"top-right": { horizontal: "right", vertical: "top" },
"bottom-left": { horizontal: "left", vertical: "bottom" },
"bottom-right": { horizontal: "right", vertical: "bottom" },
};
// 占位符组件:在紧凑模式下渲染表格块
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 420;
const MAX_WIDTH = 1400;
const MIN_HEIGHT = 320;
const MAX_HEIGHT = 900;
type ResizeHandle =
| "left"
| "right"
| "top"
| "bottom"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
const handleMapping: Record<
ResizeHandle,
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
> = {
left: { horizontal: "left" },
right: { horizontal: "right" },
top: { vertical: "top" },
bottom: { vertical: "bottom" },
"top-left": { horizontal: "left", vertical: "top" },
"top-right": { horizontal: "right", vertical: "top" },
"bottom-left": { horizontal: "left", vertical: "bottom" },
"bottom-right": { horizontal: "right", vertical: "bottom" },
};
// 占位符组件:在紧凑模式下渲染表格块
const OnlineTableBlockComponent = ({
block,
editor,
}: any) => {
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
@@ -67,27 +67,27 @@ const OnlineTableBlockComponent = ({
(next: { width: number; height: number }) => {
setDraftSize(next);
editor.updateBlock(block, {
props: {
...block.props,
width: next.width,
props: {
...block.props,
width: next.width,
height: next.height,
},
});
},
[block, editor],
);
// 阶段三:实现双击/按钮进入全屏编辑
const handleFullScreen = () => {
if (openTableFullScreen) {
openTableFullScreen(tableId);
} else {
console.error("Editor bridge not ready or openTableFullScreen missing.");
}
};
const handleDelete = useCallback(() => {
editor.removeBlocks([block.id]);
// 阶段三:实现双击/按钮进入全屏编辑
const handleFullScreen = () => {
if (openTableFullScreen) {
openTableFullScreen(tableId);
} else {
console.error("Editor bridge not ready or openTableFullScreen missing.");
}
};
const handleDelete = useCallback(() => {
editor.removeBlocks([block.id]);
}, [block.id, editor]);
const startResize = useCallback(
@@ -107,53 +107,53 @@ const OnlineTableBlockComponent = ({
const cursor =
axes.horizontal && axes.vertical
? axes.horizontal === "left"
? axes.vertical === "top"
? "nwse-resize"
: "nesw-resize"
: axes.vertical === "top"
? "nesw-resize"
: "nwse-resize"
: axes.horizontal
? "ew-resize"
: "ns-resize";
document.body.style.cursor = cursor;
const handleMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (axes.horizontal === "left") {
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
} else if (axes.horizontal === "right") {
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
} else {
nextWidth = startWidth;
}
if (axes.vertical === "top") {
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else if (axes.vertical === "bottom") {
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else {
nextHeight = startHeight;
}
setDraftSize({
width: nextWidth,
height: nextHeight,
});
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setActiveHandle(null);
commitSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
};
? axes.vertical === "top"
? "nwse-resize"
: "nesw-resize"
: axes.vertical === "top"
? "nesw-resize"
: "nwse-resize"
: axes.horizontal
? "ew-resize"
: "ns-resize";
document.body.style.cursor = cursor;
const handleMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (axes.horizontal === "left") {
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
} else if (axes.horizontal === "right") {
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
} else {
nextWidth = startWidth;
}
if (axes.vertical === "top") {
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else if (axes.vertical === "bottom") {
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else {
nextHeight = startHeight;
}
setDraftSize({
width: nextWidth,
height: nextHeight,
});
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setActiveHandle(null);
commitSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
@@ -167,90 +167,90 @@ const OnlineTableBlockComponent = ({
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
};
}, [activeHandle, committedSize, draftSize]);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
activeHandle === handle ? "is-dragging" : ""
}`;
return (
<div className="w-full overflow-auto" contentEditable={false}>
<div
className="online-table-block group relative mx-auto"
style={{ width: size.width, minWidth: MIN_WIDTH }}
>
<CompactTablePreview
tableId={tableId}
onFullScreen={handleFullScreen}
onDelete={handleDelete}
height={size.height}
/>
<button
type="button"
aria-label="向左拖拽以调整宽度"
className={handleClass("left")}
onMouseDown={startResize("left")}
/>
<button
type="button"
aria-label="向右拖拽以调整宽度"
className={handleClass("right")}
onMouseDown={startResize("right")}
/>
<button
type="button"
aria-label="向上拖拽以调整高度"
className={handleClass("top")}
onMouseDown={startResize("top")}
/>
<button
type="button"
aria-label="向下拖拽以调整高度"
className={handleClass("bottom")}
onMouseDown={startResize("bottom")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-left")}
onMouseDown={startResize("top-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-right")}
onMouseDown={startResize("top-right")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-left")}
onMouseDown={startResize("bottom-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-right")}
onMouseDown={startResize("bottom-right")}
/>
</div>
</div>
);
};
// Block Spec 定义
export const onlineTableBlock = createReactBlockSpec(
{
type: "onlineTable",
propSchema: {
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
title: { default: "未命名表格" },
width: { default: DEFAULT_WIDTH },
height: { default: DEFAULT_HEIGHT },
},
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
},
{
render: (props) => <OnlineTableBlockComponent {...props} />,
}
);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
activeHandle === handle ? "is-dragging" : ""
}`;
return (
<div className="w-full overflow-auto" contentEditable={false}>
<div
className="online-table-block group relative mx-auto"
style={{ width: size.width, minWidth: MIN_WIDTH }}
>
<CompactTablePreview
tableId={tableId}
onFullScreen={handleFullScreen}
onDelete={handleDelete}
height={size.height}
/>
<button
type="button"
aria-label="向左拖拽以调整宽度"
className={handleClass("left")}
onMouseDown={startResize("left")}
/>
<button
type="button"
aria-label="向右拖拽以调整宽度"
className={handleClass("right")}
onMouseDown={startResize("right")}
/>
<button
type="button"
aria-label="向上拖拽以调整高度"
className={handleClass("top")}
onMouseDown={startResize("top")}
/>
<button
type="button"
aria-label="向下拖拽以调整高度"
className={handleClass("bottom")}
onMouseDown={startResize("bottom")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-left")}
onMouseDown={startResize("top-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-right")}
onMouseDown={startResize("top-right")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-left")}
onMouseDown={startResize("bottom-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-right")}
onMouseDown={startResize("bottom-right")}
/>
</div>
</div>
);
};
// Block Spec 定义
export const onlineTableBlock = createReactBlockSpec(
{
type: "onlineTable",
propSchema: {
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
title: { default: "未命名表格" },
width: { default: DEFAULT_WIDTH },
height: { default: DEFAULT_HEIGHT },
},
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
},
{
render: (props) => <OnlineTableBlockComponent {...props} />,
}
);
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -17,10 +17,10 @@ import {
export interface TocEntry {
id: string;
title: string;
level: number;
numbering: string;
}
level: number;
numbering: string;
}
interface DocumentTocProps {
entries: TocEntry[];
visible: boolean;
@@ -85,12 +85,12 @@ export function DocumentToc({ entries, visible, onJump, onClose }: DocumentTocPr
onClick={() => onJump(entry.id)}
>
<span className="mr-2 font-mono text-[10px] text-gray-400">{entry.numbering}</span>
{entry.title || "未命名"}
</button>
</li>
))}
</ul>
</div>
</div>
);
}
{entry.title || "未命名"}
</button>
</li>
))}
</ul>
</div>
</div>
);
}
@@ -682,39 +682,42 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
) : null}
{!showEmptyPlus ? (
<Components.Generic.Menu.Trigger>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave
// 这里用 :hover 兜底,避免插入按钮一闪而过。
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering && !menuOpen) {
setHovering(false);
setInsertHovered(null);
setFrozen(false);
}
});
}}
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
icon={
<span className="relative inline-flex">
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
{unresolvedCount > 0 ? (
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
{unresolvedCount > 9 ? "9+" : unresolvedCount}
<div
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave
// 这里用 :hover 兜底,避免插入按钮一闪而过。
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering && !menuOpen) {
setHovering(false);
setInsertHovered(null);
setFrozen(false);
}
});
}}
>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
icon={
<span className="relative inline-flex">
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
{unresolvedCount > 0 ? (
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
{unresolvedCount > 9 ? "9+" : unresolvedCount}
</span>
) : null}
</span>
) : null}
</span>
}
/>
}
/>
</div>
</Components.Generic.Menu.Trigger>
) : null}
</div>
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import type { JSX } from "react";
"use client";
import { useCallback, useMemo } from "react";
import type { JSX } from "react";
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
@@ -9,34 +9,34 @@ import {
} from "@blocknote/react";
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
import { useRouter } from "next/navigation";
import {
FileImage,
FilePlus2,
FileVideo,
ListTree,
Music,
Paperclip,
PilcrowSquare,
Play,
Spline,
Sparkles,
SquareCheckBig,
Table,
} from "lucide-react";
import type { CustomBlockSchema } from "../schema";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind, MediaSelection } from "@/types/media";
import { createOnlineTable } from "@/lib/online-table";
type Props = {
editor: BlockNoteEditor<CustomBlockSchema>;
currentDocumentId: string;
};
import {
FileImage,
FilePlus2,
FileVideo,
ListTree,
Music,
Paperclip,
PilcrowSquare,
Play,
Spline,
Sparkles,
SquareCheckBig,
Table,
} from "lucide-react";
import type { CustomBlockSchema } from "../schema";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind, MediaSelection } from "@/types/media";
import { createOnlineTable } from "@/lib/online-table";
type Props = {
editor: BlockNoteEditor<CustomBlockSchema>;
currentDocumentId: string;
};
const matchKeywords = (query: string, aliases: string[]) => {
const lower = query.trim().toLowerCase();
if (!lower) return true;
return aliases.some((alias) => alias.toLowerCase().includes(lower));
const lower = query.trim().toLowerCase();
if (!lower) return true;
return aliases.some((alias) => alias.toLowerCase().includes(lower));
};
function insertOrUpdateBlockForSlashMenuCompat(
@@ -100,95 +100,95 @@ function insertOrUpdateBlockForSlashMenuCompat(
editor.insertBlocks([partialBlock as never], referenceBlock, "after");
}
const GROUP_TRANSLATIONS: Record<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
"Basic blocks": "基础块",
"Advanced": "高级",
"Media": "媒体",
"Others": "其他",
};
const DEFAULT_ITEM_TRANSLATIONS: Record<
string,
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
> = {
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
};
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
audio: <Music className="h-4 w-4 text-[#10b981]" />,
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
};
const HEADING_PRESETS = [
{
level: 1,
title: "主标题",
subtext: "适合页面名称/顶层章节",
aliases: ["biaoti1", "h1", "level1"],
},
{
level: 2,
title: "大标题",
subtext: "用于章节逻辑层",
aliases: ["biaoti2", "h2", "level2"],
},
{
level: 3,
title: "中标题",
subtext: "用于小节和段落",
aliases: ["biaoti3", "h3", "level3"],
},
{
level: 4,
title: "小标题",
subtext: "更细的结构说明",
aliases: ["biaoti4", "h4", "level4"],
},
{
level: 5,
title: "极小标题",
subtext: "适合脚注/补充说明",
aliases: ["biaoti5", "h5", "level5"],
},
];
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
const maybeKey = (item as { key?: string }).key ?? "";
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
return true;
}
const title = item.title ?? "";
return title.includes("标题");
};
const GROUP_TRANSLATIONS: Record<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
"Basic blocks": "基础块",
"Advanced": "高级",
"Media": "媒体",
"Others": "其他",
};
const DEFAULT_ITEM_TRANSLATIONS: Record<
string,
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
> = {
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
};
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
audio: <Music className="h-4 w-4 text-[#10b981]" />,
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
};
const HEADING_PRESETS = [
{
level: 1,
title: "主标题",
subtext: "适合页面名称/顶层章节",
aliases: ["biaoti1", "h1", "level1"],
},
{
level: 2,
title: "大标题",
subtext: "用于章节逻辑层",
aliases: ["biaoti2", "h2", "level2"],
},
{
level: 3,
title: "中标题",
subtext: "用于小节和段落",
aliases: ["biaoti3", "h3", "level3"],
},
{
level: 4,
title: "小标题",
subtext: "更细的结构说明",
aliases: ["biaoti4", "h4", "level4"],
},
{
level: 5,
title: "极小标题",
subtext: "适合脚注/补充说明",
aliases: ["biaoti5", "h5", "level5"],
},
];
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
const maybeKey = (item as { key?: string }).key ?? "";
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
return true;
}
const title = item.title ?? "";
return title.includes("标题");
};
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
const router = useRouter();
@@ -220,7 +220,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
}
},
};
const createMindmapItem: DefaultReactSuggestionItem = {
title: "思维导图",
group: "高级",
@@ -235,7 +235,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
};
const createPageItem: DefaultReactSuggestionItem = {
title: "嵌入页面",
group: "嵌入",
@@ -268,12 +268,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
router.refresh();
},
};
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
title: preset.title,
group: "标题",
subtext: preset.subtext,
aliases: preset.aliases,
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
title: preset.title,
group: "标题",
subtext: preset.subtext,
aliases: preset.aliases,
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
@@ -283,10 +283,10 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
}));
const foldHeading: DefaultReactSuggestionItem = {
title: "折叠标题",
group: "标题",
const foldHeading: DefaultReactSuggestionItem = {
title: "折叠标题",
group: "标题",
aliases: ["toggle", "zd", "fold"],
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
@@ -297,12 +297,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
};
const advancedTodo: DefaultReactSuggestionItem = {
title: "高级待办",
group: "待办",
subtext: "四态状态 · Alt 直接取消",
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
const advancedTodo: DefaultReactSuggestionItem = {
title: "高级待办",
group: "待办",
subtext: "四态状态 · Alt 直接取消",
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
@@ -312,12 +312,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
};
const progressMeter: DefaultReactSuggestionItem = {
title: "进度条",
group: "进度",
subtext: "自动读取下方待办完成度",
aliases: ["jdt", "progress", "jindu"],
const progressMeter: DefaultReactSuggestionItem = {
title: "进度条",
group: "进度",
subtext: "自动读取下方待办完成度",
aliases: ["jdt", "progress", "jindu"],
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
@@ -327,10 +327,10 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
};
const foldAdvancedTodo: DefaultReactSuggestionItem = {
title: "折叠高级待办",
group: "待办",
const foldAdvancedTodo: DefaultReactSuggestionItem = {
title: "折叠高级待办",
group: "待办",
aliases: ["zdgjdb", "foldtodo"],
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
onItemClick: () => {
@@ -341,18 +341,18 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
});
},
};
const customItems = [
...headingItems,
foldHeading,
createPageItem,
createTableItem,
createMindmapItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
].filter((item) => matchKeywords(query, item.aliases ?? []));
const customItems = [
...headingItems,
foldHeading,
createPageItem,
createTableItem,
createMindmapItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
].filter((item) => matchKeywords(query, item.aliases ?? []));
const insertMediaSelection = (selection: MediaSelection) => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "media",
@@ -369,7 +369,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
content: [],
});
};
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
@@ -379,41 +379,41 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
...selection,
assetType: selection.assetType ?? mediaType,
});
},
});
};
const localizedDefaults = defaultItems.map((item) => {
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
const next: DefaultReactSuggestionItem = { ...item };
if (translation?.title) next.title = translation.title;
if (translation?.subtext) next.subtext = translation.subtext;
if (translation?.aliases) next.aliases = translation.aliases;
if (translation?.group) {
next.group = translation.group;
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
next.group = GROUP_TRANSLATIONS[item.group];
}
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
const mediaType = item.title.toLowerCase() as MediaKind;
next.icon = MEDIA_ICONS[mediaType];
next.group = translation?.group ?? "媒体";
next.subtext = translation?.subtext ?? next.subtext;
next.aliases = translation?.aliases ?? next.aliases;
next.onItemClick = () => handleMediaPick(mediaType);
}
return next;
});
const sanitizedDefaults = localizedDefaults.filter(
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
);
const merged = [...customItems, ...sanitizedDefaults];
return filterSuggestionItems(merged, query);
},
[currentDocumentId, defaultItems, editor, openPicker, router],
);
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
}
},
});
};
const localizedDefaults = defaultItems.map((item) => {
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
const next: DefaultReactSuggestionItem = { ...item };
if (translation?.title) next.title = translation.title;
if (translation?.subtext) next.subtext = translation.subtext;
if (translation?.aliases) next.aliases = translation.aliases;
if (translation?.group) {
next.group = translation.group;
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
next.group = GROUP_TRANSLATIONS[item.group];
}
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
const mediaType = item.title.toLowerCase() as MediaKind;
next.icon = MEDIA_ICONS[mediaType];
next.group = translation?.group ?? "媒体";
next.subtext = translation?.subtext ?? next.subtext;
next.aliases = translation?.aliases ?? next.aliases;
next.onItemClick = () => handleMediaPick(mediaType);
}
return next;
});
const sanitizedDefaults = localizedDefaults.filter(
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
);
const merged = [...customItems, ...sanitizedDefaults];
return filterSuggestionItems(merged, query);
},
[currentDocumentId, defaultItems, editor, openPicker, router],
);
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
}
+19 -19
View File
@@ -1,26 +1,26 @@
"use client";
import {
BlockNoteSchema,
createHeadingBlockSpec,
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "@blocknote/core";
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
"use client";
import {
BlockNoteSchema,
createHeadingBlockSpec,
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "@blocknote/core";
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
import { advancedTodoBlock } from "./blocks/AdvancedTodoBlock";
import { progressBlock } from "./blocks/ProgressBlock";
import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
export const customBlockSchema = BlockNoteSchema.create({
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
export const customBlockSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
heading: headingSpec,
@@ -35,5 +35,5 @@ export const customBlockSchema = BlockNoteSchema.create({
inlineContentSpecs: defaultInlineContentSpecs,
styleSpecs: defaultStyleSpecs,
});
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
@@ -60,19 +60,19 @@ const useTableData = (tableId: string) => {
.then((data) => {
if (!canceled) {
setTable({ ...data, title: data.title || "未命名表格" });
}
})
.catch((error) => {
console.error("Failed to load table:", error);
if (!canceled) {
setTable(null);
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
}
})
.catch((error) => {
console.error("Failed to load table:", error);
if (!canceled) {
setTable(null);
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
canceled = true;
};
@@ -87,254 +87,254 @@ const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
onDelete,
height,
}) => {
const { table, isLoading, refresh } = useTableData(tableId);
const [iframeVersion, setIframeVersion] = useState(0);
const [iframeLoading, setIframeLoading] = useState(true);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false);
const fixedViewerHeight = 320; // 默认视窗高度
const minEmbedHeight = 260;
const maxEmbedHeight = 440;
const rowHeight = 26; // 预估单行高度,便于动态收缩高度
const iframeSrc = useMemo(
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
[tableId, iframeVersion],
);
useEffect(() => {
const handleSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
}
};
const handleDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
}
};
window.addEventListener("online-table-saved", handleSaved as EventListener);
window.addEventListener("online-table-deleted", handleDeleted as EventListener);
return () => {
window.removeEventListener("online-table-saved", handleSaved as EventListener);
window.removeEventListener("online-table-deleted", handleDeleted as EventListener);
};
}, [refresh, tableId]);
useEffect(() => {
if (table?.title !== undefined) {
setRenameValue(table.title ?? "");
}
}, [table?.title]);
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const { table, isLoading, refresh } = useTableData(tableId);
const [iframeVersion, setIframeVersion] = useState(0);
const [iframeLoading, setIframeLoading] = useState(true);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false);
const fixedViewerHeight = 320; // 默认视窗高度
const minEmbedHeight = 260;
const maxEmbedHeight = 440;
const rowHeight = 26; // 预估单行高度,便于动态收缩高度
const iframeSrc = useMemo(
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
[tableId, iframeVersion],
);
useEffect(() => {
const handleSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
}
};
const handleDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
}
};
window.addEventListener("online-table-saved", handleSaved as EventListener);
window.addEventListener("online-table-deleted", handleDeleted as EventListener);
return () => {
window.removeEventListener("online-table-saved", handleSaved as EventListener);
window.removeEventListener("online-table-deleted", handleDeleted as EventListener);
};
}, [refresh, tableId]);
useEffect(() => {
if (table?.title !== undefined) {
setRenameValue(table.title ?? "");
}
}, [table?.title]);
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const handleDeleteTable = useCallback(async () => {
const confirmed = window.confirm("删除表格将同步移除在线表格记录,确认继续?");
if (!confirmed) return;
try {
await deleteOnlineTable(tableId);
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
onDelete?.();
} catch (error) {
console.error("删除表格失败", error);
window.alert("删除失败,请稍后重试");
}
}, [onDelete, tableId]);
const handleRefresh = useCallback(() => {
setIframeVersion((value) => value + 1);
setIframeLoading(true);
refresh();
}, [refresh]);
const estimatedRows = useMemo(() => {
const rowsBySnapshot =
Array.isArray(table?.snapshot?.rows) && table.snapshot?.rows
? table.snapshot.rows.length
: 0;
const celldata = table?.snapshot?.luckysheet?.[0]?.celldata;
const rowsByCells =
Array.isArray(celldata) && celldata.length > 0
? Math.max(
...celldata.map((cell) =>
typeof cell?.r === "number" ? cell.r : -1,
),
) + 1
: 0;
const fallbackRows = 10;
return Math.max(rowsBySnapshot, rowsByCells, fallbackRows);
}, [table?.snapshot]);
const clampHeight = useCallback(
(value: number) => Math.min(maxEmbedHeight, Math.max(minEmbedHeight, value)),
[maxEmbedHeight, minEmbedHeight],
);
const autoHeight = clampHeight(estimatedRows * rowHeight);
const effectiveHeight = clampHeight(height ?? autoHeight ?? fixedViewerHeight);
const effectiveWidth: number | string = "100%";
const handleRenameSubmit = useCallback(async () => {
if (!table) {
return;
}
const nextTitle = (renameValue || "").trim() || "未命名表格";
if (nextTitle === table.title) {
setIsRenaming(false);
return;
}
setIsSavingTitle(true);
try {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
const finalTitle = updated.title ?? nextTitle;
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
refresh();
setRenameValue(finalTitle);
} catch (error) {
console.error("重命名表格失败", error);
setRenameValue(table.title ?? "");
} finally {
setIsRenaming(false);
setIsSavingTitle(false);
}
}, [refresh, renameValue, table, tableId]);
if (isLoading) {
return (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
}
if (!table) {
return (
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
<div className="flex items-center gap-2 text-sm">
<TableIcon className="h-5 w-5" />
<span></span>
</div>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
>
<RotateCw className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className="w-full"
onDoubleClick={onFullScreen}
contentEditable={false}
onMouseDown={suppressEditorEvents}
onMouseUp={suppressEditorEvents}
onDelete?.();
} catch (error) {
console.error("删除表格失败", error);
window.alert("删除失败,请稍后重试");
}
}, [onDelete, tableId]);
const handleRefresh = useCallback(() => {
setIframeVersion((value) => value + 1);
setIframeLoading(true);
refresh();
}, [refresh]);
const estimatedRows = useMemo(() => {
const rowsBySnapshot =
Array.isArray(table?.snapshot?.rows) && table.snapshot?.rows
? table.snapshot.rows.length
: 0;
const celldata = table?.snapshot?.luckysheet?.[0]?.celldata;
const rowsByCells =
Array.isArray(celldata) && celldata.length > 0
? Math.max(
...celldata.map((cell) =>
typeof cell?.r === "number" ? cell.r : -1,
),
) + 1
: 0;
const fallbackRows = 10;
return Math.max(rowsBySnapshot, rowsByCells, fallbackRows);
}, [table?.snapshot]);
const clampHeight = useCallback(
(value: number) => Math.min(maxEmbedHeight, Math.max(minEmbedHeight, value)),
[maxEmbedHeight, minEmbedHeight],
);
const autoHeight = clampHeight(estimatedRows * rowHeight);
const effectiveHeight = clampHeight(height ?? autoHeight ?? fixedViewerHeight);
const effectiveWidth: number | string = "100%";
const handleRenameSubmit = useCallback(async () => {
if (!table) {
return;
}
const nextTitle = (renameValue || "").trim() || "未命名表格";
if (nextTitle === table.title) {
setIsRenaming(false);
return;
}
setIsSavingTitle(true);
try {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
const finalTitle = updated.title ?? nextTitle;
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
refresh();
setRenameValue(finalTitle);
} catch (error) {
console.error("重命名表格失败", error);
setRenameValue(table.title ?? "");
} finally {
setIsRenaming(false);
setIsSavingTitle(false);
}
}, [refresh, renameValue, table, tableId]);
if (isLoading) {
return (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
}
if (!table) {
return (
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
<div className="flex items-center gap-2 text-sm">
<TableIcon className="h-5 w-5" />
<span></span>
</div>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
>
<RotateCw className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className="w-full"
onDoubleClick={onFullScreen}
contentEditable={false}
onMouseDown={suppressEditorEvents}
onMouseUp={suppressEditorEvents}
onMouseMove={suppressEditorEvents}
>
<div
className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white/90 shadow-[0_10px_36px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_14px_44px_rgba(15,23,42,0.08)]"
style={{
width: effectiveWidth,
maxWidth: "100%",
marginLeft: "auto",
marginRight: "auto",
overflowX: "hidden",
}}
>
<div className="flex items-center justify-between border-b border-gray-100 bg-white/80 px-4 py-2 backdrop-blur-sm">
<div className="flex flex-col">
{isRenaming ? (
<input
autoFocus
className="w-48 rounded border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-700 focus:border-emerald-500 focus:outline-none"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleRenameSubmit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleRenameSubmit();
}
if (e.key === "Escape") {
e.preventDefault();
setRenameValue(table.title ?? "");
setIsRenaming(false);
}
}}
/>
) : (
<button
type="button"
className="flex items-center gap-2 text-left text-sm font-semibold text-gray-700 hover:text-emerald-600"
title="点击重命名表格"
onClick={() => setIsRenaming(true)}
>
<span className="truncate max-w-xs">{table.title}</span>
{isSavingTitle && <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />}
</button>
)}
<p className="text-xs text-gray-400"> · </p>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleRefresh}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
title="刷新嵌入视图"
type="button"
>
<RotateCw className="h-4 w-4" />
</button>
<button
onClick={handleDeleteTable}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
title="删除表格"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
title="进入全屏编辑"
type="button"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
<div
className="relative w-full overflow-hidden bg-white select-none px-4 pb-4 pt-3"
style={{ height: effectiveHeight, minHeight: minEmbedHeight }}
>
<div className="relative h-full w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
<iframe
key={`${tableId}-${iframeVersion}`}
src={iframeSrc}
title={`online-table-${tableId}`}
className="block h-full w-full border-0"
loading="lazy"
onLoad={() => setIframeLoading(false)}
allow="clipboard-read; clipboard-write"
/>
</div>
{iframeLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-white/90">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
<span className="text-xs text-gray-500"> Luckysheet ...</span>
</div>
)}
</div>
</div>
</div>
<div
className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white/90 shadow-[0_10px_36px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_14px_44px_rgba(15,23,42,0.08)]"
style={{
width: effectiveWidth,
maxWidth: "100%",
marginLeft: "auto",
marginRight: "auto",
overflowX: "hidden",
}}
>
<div className="flex items-center justify-between border-b border-gray-100 bg-white/80 px-4 py-2 backdrop-blur-sm">
<div className="flex flex-col">
{isRenaming ? (
<input
autoFocus
className="w-48 rounded border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-700 focus:border-emerald-500 focus:outline-none"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleRenameSubmit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleRenameSubmit();
}
if (e.key === "Escape") {
e.preventDefault();
setRenameValue(table.title ?? "");
setIsRenaming(false);
}
}}
/>
) : (
<button
type="button"
className="flex items-center gap-2 text-left text-sm font-semibold text-gray-700 hover:text-emerald-600"
title="点击重命名表格"
onClick={() => setIsRenaming(true)}
>
<span className="truncate max-w-xs">{table.title}</span>
{isSavingTitle && <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />}
</button>
)}
<p className="text-xs text-gray-400"> · </p>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleRefresh}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
title="刷新嵌入视图"
type="button"
>
<RotateCw className="h-4 w-4" />
</button>
<button
onClick={handleDeleteTable}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
title="删除表格"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
title="进入全屏编辑"
type="button"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
<div
className="relative w-full overflow-hidden bg-white select-none px-4 pb-4 pt-3"
style={{ height: effectiveHeight, minHeight: minEmbedHeight }}
>
<div className="relative h-full w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
<iframe
key={`${tableId}-${iframeVersion}`}
src={iframeSrc}
title={`online-table-${tableId}`}
className="block h-full w-full border-0"
loading="lazy"
onLoad={() => setIframeLoading(false)}
allow="clipboard-read; clipboard-write"
/>
</div>
{iframeLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-white/90">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
<span className="text-xs text-gray-500"> Luckysheet ...</span>
</div>
)}
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -17,39 +17,39 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
type LuckysheetSelection =
| {
row?: [number, number];
column?: [number, number];
row_focus?: number;
column_focus?: number;
}
| undefined;
const isPrintableKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
if (event.key.length === 1) return true;
return event.key === "Process" || event.key === "Unidentified";
};
const isInlineEditorVisible = () => {
const inputBox = document.getElementById("luckysheet-input-box");
if (!inputBox) {
return false;
}
const style = window.getComputedStyle(inputBox);
return style.top !== "-10000px" && style.display !== "none";
};
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
editable?: boolean;
}
type LuckysheetSelection =
| {
row?: [number, number];
column?: [number, number];
row_focus?: number;
column_focus?: number;
}
| undefined;
const isPrintableKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
if (event.key.length === 1) return true;
return event.key === "Process" || event.key === "Unidentified";
};
const isInlineEditorVisible = () => {
const inputBox = document.getElementById("luckysheet-input-box");
if (!inputBox) {
return false;
}
const style = window.getComputedStyle(inputBox);
return style.top !== "-10000px" && style.display !== "none";
};
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
editable?: boolean;
}
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
@@ -87,15 +87,15 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
if (savingHintTimerRef.current !== null) return;
savingHintTimerRef.current = window.setTimeout(() => {
setShowSavingHint(true);
}, 700);
}, []);
const stopSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) {
clearTimeout(savingHintTimerRef.current);
savingHintTimerRef.current = null;
}
setShowSavingHint(false);
}, 700);
}, []);
const stopSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) {
clearTimeout(savingHintTimerRef.current);
savingHintTimerRef.current = null;
}
setShowSavingHint(false);
}, []);
useEffect(() => {
@@ -154,71 +154,71 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
canceled = true;
};
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
const focusLuckysheetEditor = useCallback(() => {
const applyFocus = () => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && typeof editor.focus === "function") {
editor.focus();
const selection = window.getSelection();
if (selection && editor.childNodes.length > 0) {
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
requestAnimationFrame(() => {
applyFocus();
setTimeout(applyFocus, 0);
});
}, []);
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
if (!Array.isArray(range) || range.length !== 1) return false;
const target = range[0];
if (!target) {
return false;
}
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
if (!rowRange || !columnRange) {
return false;
}
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
}, []);
const tryEnterInlineEdit = useCallback(() => {
if (!allowInlineEdit) {
return false;
}
const luckysheetInstance = window.luckysheet;
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
return false;
}
const selection = luckysheetInstance.getluckysheet_select_save?.();
const normalized = Array.isArray(selection)
? (selection as LuckysheetSelection[])
: selection
? [selection as LuckysheetSelection]
: undefined;
if (!isSingleCellSelection(normalized)) {
return false;
}
setTimeout(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
const selection = window.getSelection();
if (selection && editor.childNodes.length > 0) {
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
requestAnimationFrame(() => {
applyFocus();
setTimeout(applyFocus, 0);
});
}, []);
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
if (!Array.isArray(range) || range.length !== 1) return false;
const target = range[0];
if (!target) {
return false;
}
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
if (!rowRange || !columnRange) {
return false;
}
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
}, []);
const tryEnterInlineEdit = useCallback(() => {
if (!allowInlineEdit) {
return false;
}
const luckysheetInstance = window.luckysheet;
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
return false;
}
const selection = luckysheetInstance.getluckysheet_select_save?.();
const normalized = Array.isArray(selection)
? (selection as LuckysheetSelection[])
: selection
? [selection as LuckysheetSelection]
: undefined;
if (!isSingleCellSelection(normalized)) {
return false;
}
setTimeout(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && document.activeElement === editor && isInlineEditorVisible()) {
return;
}
luckysheetInstance.enterEditMode?.();
focusLuckysheetEditor();
}, 0);
return true;
}, [allowInlineEdit, focusLuckysheetEditor, isSingleCellSelection]);
return true;
}, [allowInlineEdit, focusLuckysheetEditor, isSingleCellSelection]);
const persistSnapshot = useCallback(async () => {
if (!allowInlineEdit || !table || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
return;
@@ -262,60 +262,60 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
setIsSaving(false);
}
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
stopSavingHint();
};
}, [debouncedPersist, stopSavingHint]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
stopSavingHint();
};
}, [debouncedPersist, stopSavingHint]);
useEffect(() => {
if (!tableId) return;
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex
}, [tableId]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
return;
}
if (typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
containerRef.current.innerHTML = "";
const sheets =
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
? table.snapshot.luckysheet
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
return;
}
if (typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
containerRef.current.innerHTML = "";
const sheets =
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
? table.snapshot.luckysheet
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
window.luckysheet?.create?.({
container: containerId,
title: table.title ?? tableId,
lang: "zh",
showinfobar: false,
showtoolbar: false,
showsheetbar: sheets.length > 1,
showstatisticBar: false,
allowEdit: allowInlineEdit,
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
pointEdit: allowInlineEdit,
pointEditZoom: window.devicePixelRatio ?? 1,
pointEditUpdate: allowInlineEdit
? () => {
debouncedPersist();
}
: undefined,
showsheetbar: sheets.length > 1,
showstatisticBar: false,
allowEdit: allowInlineEdit,
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
pointEdit: allowInlineEdit,
pointEditZoom: window.devicePixelRatio ?? 1,
pointEditUpdate: allowInlineEdit
? () => {
debouncedPersist();
}
: undefined,
hook: allowInlineEdit
? {
updated: () => {
@@ -324,22 +324,22 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
}
: undefined,
} as any);
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
useEffect(() => {
if (!allowInlineEdit || !isLuckysheetReady) {
return;
}
const container = document.getElementById(containerId);
if (!container) {
return;
}
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
useEffect(() => {
if (!allowInlineEdit || !isLuckysheetReady) {
return;
}
const container = document.getElementById(containerId);
if (!container) {
return;
}
const handlePointerUp: EventListener = (event) => {
const target = event.target instanceof Node ? event.target : null;
if (target && !container.contains(target)) {
@@ -352,100 +352,100 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
events.forEach((eventName) => container.removeEventListener(eventName, handlePointerUp, true));
};
}, [allowInlineEdit, containerId, isLuckysheetReady, tryEnterInlineEdit]);
useEffect(() => {
if (!allowInlineEdit || !isLuckysheetReady) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isPrintableKey(event)) {
return;
}
tryEnterInlineEdit();
};
const handleCompositionStart = () => {
tryEnterInlineEdit();
};
window.addEventListener("keydown", handleKeydown, true);
window.addEventListener("compositionstart", handleCompositionStart, true);
return () => {
window.removeEventListener("keydown", handleKeydown, true);
window.removeEventListener("compositionstart", handleCompositionStart, true);
};
}, [allowInlineEdit, isLuckysheetReady, tryEnterInlineEdit]);
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const embedContainerStyle = embed
? { height: "360px", minHeight: "360px", width: "100%", overflow: "hidden" as const }
: undefined;
useEffect(() => {
if (!embed) return;
const prevDocOverflow = document.documentElement.style.overflow;
const prevBodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = prevDocOverflow;
document.body.style.overflow = prevBodyOverflow;
};
}, [embed]);
return (
<div
className={
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
}
style={embedContainerStyle}
>
<div
className={
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
}
style={embedContainerStyle}
>
<div
id={containerId}
ref={containerRef}
className="h-full w-full"
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
/>
{overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
<span className="text-sm text-gray-500">{overlayText}</span>
</div>
)}
{error && !overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
<span className="text-sm">{error}</span>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
onClick={() => setReloadVersion((value) => value + 1)}
>
<RotateCw className="h-4 w-4" />
</button>
</div>
)}
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
{isSaving && showSavingHint && (
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">...</span>
)}
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
</div>
)}
</div>
</div>
);
};
export default HeadlessTableViewer;
}, [allowInlineEdit, containerId, isLuckysheetReady, tryEnterInlineEdit]);
useEffect(() => {
if (!allowInlineEdit || !isLuckysheetReady) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isPrintableKey(event)) {
return;
}
tryEnterInlineEdit();
};
const handleCompositionStart = () => {
tryEnterInlineEdit();
};
window.addEventListener("keydown", handleKeydown, true);
window.addEventListener("compositionstart", handleCompositionStart, true);
return () => {
window.removeEventListener("keydown", handleKeydown, true);
window.removeEventListener("compositionstart", handleCompositionStart, true);
};
}, [allowInlineEdit, isLuckysheetReady, tryEnterInlineEdit]);
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const embedContainerStyle = embed
? { height: "360px", minHeight: "360px", width: "100%", overflow: "hidden" as const }
: undefined;
useEffect(() => {
if (!embed) return;
const prevDocOverflow = document.documentElement.style.overflow;
const prevBodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = prevDocOverflow;
document.body.style.overflow = prevBodyOverflow;
};
}, [embed]);
return (
<div
className={
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
}
style={embedContainerStyle}
>
<div
className={
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
}
style={embedContainerStyle}
>
<div
id={containerId}
ref={containerRef}
className="h-full w-full"
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
/>
{overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
<span className="text-sm text-gray-500">{overlayText}</span>
</div>
)}
{error && !overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
<span className="text-sm">{error}</span>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
onClick={() => setReloadVersion((value) => value + 1)}
>
<RotateCw className="h-4 w-4" />
</button>
</div>
)}
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
{isSaving && showSavingHint && (
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">...</span>
)}
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
</div>
)}
</div>
</div>
);
};
export default HeadlessTableViewer;
@@ -1,61 +1,61 @@
import type { TableRowData } from "@/types/online-table";
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
const pickCellValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m != null) return cell.m;
if (cell.v?.m != null) return cell.v.m;
if (cell.v?.v != null) return cell.v.v;
if (cell.v != null && typeof cell.v !== "object") return cell.v;
if (cell.w != null) return cell.w;
return undefined;
};
export const extractRowsForPreview = (
luckysheetData: any,
columns: Array<{ id: string }>,
): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) {
return [];
}
const columnIds =
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
import type { TableRowData } from "@/types/online-table";
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
const pickCellValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m != null) return cell.m;
if (cell.v?.m != null) return cell.v.m;
if (cell.v?.v != null) return cell.v.v;
if (cell.v != null && typeof cell.v !== "object") return cell.v;
if (cell.w != null) return cell.w;
return undefined;
};
export const extractRowsForPreview = (
luckysheetData: any,
columns: Array<{ id: string }>,
): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) {
return [];
}
const columnIds =
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
@@ -9,10 +9,12 @@ import { clamp } from "@/lib/constants";
import { useAppPreferencesStore } from "@/store/app-preferences";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "info"; message: string }
| { type: "error"; message: string };
type PanelPage = "chat" | "tools" | "settings";
@@ -37,6 +39,8 @@ const ONLINE_MODELS = [
"gemini-3-flash-preview",
] as const;
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
@@ -95,10 +99,13 @@ export function OnlyOfficeAiAgentPanel({
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
const [pluginReady, setPluginReady] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@@ -131,7 +138,7 @@ export function OnlyOfficeAiAgentPanel({
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
const parsed = Number(stepsRaw);
if (providerRaw === "online" || providerRaw === "local") setAiProvider(providerRaw);
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
if (typeof modelRaw === "string") setAiModel(modelRaw);
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
} catch {
@@ -281,6 +288,9 @@ export function OnlyOfficeAiAgentPanel({
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
if (aiProvider === "codex") {
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
}
};
const send = async () => {
@@ -288,6 +298,9 @@ export function OnlyOfficeAiAgentPanel({
if (!text) return;
if (loading) return;
const codexSessionIdForRequest =
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
setMessages(nextMessages);
setInput("");
@@ -318,7 +331,14 @@ export function OnlyOfficeAiAgentPanel({
"toolset.onlyoffice_editor",
],
},
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel } },
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
},
},
}),
});
if (!res.ok) {
@@ -329,6 +349,19 @@ export function OnlyOfficeAiAgentPanel({
}
await parseSseChunks(res, (event, dataText) => {
if (event === "codex_session") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
const sid = String(obj.sessionId ?? "").trim();
if (sid) {
setCodexSessionId(sid);
}
} catch {
// ignore
}
return;
}
if (event === "assistant_message") {
try {
const d = JSON.parse(dataText || "null") as unknown;
@@ -386,6 +419,7 @@ export function OnlyOfficeAiAgentPanel({
}
if (event === "error") {
if (controller.signal.aborted && aiProvider === "codex") return;
try {
const d = JSON.parse(dataText || "null") as unknown;
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
@@ -397,6 +431,7 @@ export function OnlyOfficeAiAgentPanel({
}
});
} catch (e) {
if (controller.signal.aborted && aiProvider === "codex") return;
const msg = e instanceof Error ? e.message : String(e);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
} finally {
@@ -495,6 +530,13 @@ export function OnlyOfficeAiAgentPanel({
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
@@ -544,29 +586,65 @@ export function OnlyOfficeAiAgentPanel({
<select
className="rounded border px-2 py-1 text-xs"
value={aiProvider}
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading || aiProvider !== "online"}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
{aiProvider === "codex" ? (
<div className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
list="oo-local-model-suggestions"
/>
)}
</label>
<datalist id="oo-local-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
) : null}
@@ -602,7 +680,7 @@ export function OnlyOfficeAiAgentPanel({
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
{aiProvider === "codex" ? "暂停" : "停止"}
</Button>
</div>
</div>
@@ -2,11 +2,11 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [client] = useState(
() =>
@@ -1,106 +1,106 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
onOpen: (asset: MediaAsset) => void;
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: (assetIds: string[]) => void;
onDownload: (asset: MediaAsset) => void;
}
export function AssetContextMenu({
asset,
position,
onClose,
onOpen,
onCopyLink,
onCopyPath,
onRename,
onMove,
onDelete,
onDownload,
}: AssetContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
onOpen: (asset: MediaAsset) => void;
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: (assetIds: string[]) => void;
onDownload: (asset: MediaAsset) => void;
}
export function AssetContextMenu({
asset,
position,
onClose,
onOpen,
onCopyLink,
onCopyPath,
onRename,
onMove,
onDelete,
onDownload,
}: AssetContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState(position);
useLayoutEffect(() => {
const clampPosition = () => {
const element = menuRef.current;
if (!element) {
setPos(position);
return;
}
const rect = element.getBoundingClientRect();
const padding = 12;
useLayoutEffect(() => {
const clampPosition = () => {
const element = menuRef.current;
if (!element) {
setPos(position);
return;
}
const rect = element.getBoundingClientRect();
const padding = 12;
const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
const left = Math.min(Math.max(padding, position.x), maxLeft);
const top = Math.min(Math.max(padding, position.y), maxTop);
setPos({ x: left, y: top });
};
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [position]);
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [onClose]);
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
return (
<div
ref={menuRef}
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [position]);
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [onClose]);
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
return (
<div
ref={menuRef}
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
style={{ top: pos.y, left: pos.x, minWidth: 200 }}
>
<button type="button" className={buttonClass} onClick={() => onOpen(asset)}>
<LinkIcon className="h-4 w-4 text-[#2563eb]" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onDownload(asset)}>
<Download className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyLink(asset)}>
<Copy className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyPath(asset)}>
<Hash className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => onRename(asset)}>
<PenLine className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onMove(asset)}>
<Move className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete([asset.id])}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
</div>
);
}
>
<button type="button" className={buttonClass} onClick={() => onOpen(asset)}>
<LinkIcon className="h-4 w-4 text-[#2563eb]" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onDownload(asset)}>
<Download className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyLink(asset)}>
<Copy className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyPath(asset)}>
<Hash className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => onRename(asset)}>
<PenLine className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onMove(asset)}>
<Move className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete([asset.id])}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
</div>
);
}
+12 -12
View File
@@ -1,17 +1,17 @@
import type { DocumentRecord } from "@/lib/documents";
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
export interface TrashRecord {
id: string;
title: string | null;
deleted_at: string;
parent_id: string | null;
access_scope: DocumentRecord["access_scope"];
}
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
export interface TrashRecord {
id: string;
title: string | null;
deleted_at: string;
parent_id: string | null;
access_scope: DocumentRecord["access_scope"];
}
export interface SidebarInitialData {
activeWorkspaceId: string;
workspaces: WorkspaceSummary[];