Initial commit: DSH mind-map canvas monorepo

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 08:53:37 +08:00
commit 2ecd300ada
92 changed files with 20486 additions and 0 deletions

32
apps/server/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@mind-map/server",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@hono/node-server": "^1.14.1",
"@mind-map/dream-skin": "workspace:*",
"@mind-map/graph-core": "workspace:*",
"@mind-map/graph-store": "workspace:*",
"@mind-map/layout-engine": "workspace:*",
"@mind-map/shared": "workspace:*",
"hono": "^4.7.5",
"ai": "^4.3.16",
"@ai-sdk/openai": "^1.3.22",
"ws": "^8.18.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/ws": "^8.18.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

206
apps/server/src/chat.ts Normal file
View file

@ -0,0 +1,206 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, tool, type CoreMessage } from "ai";
import { z } from "zod";
import type { ToolContext } from "./tools.js";
import { buildSpatialContext, runTool } from "./tools.js";
export function llmConfigured(): boolean {
return Boolean(process.env.LLM_API_KEY || process.env.OPENAI_API_KEY);
}
export function llmStatus(sourceHint?: "dsh" | "env" | "none") {
const configured = llmConfigured();
const viaDsh =
sourceHint === "dsh" ||
process.env.MINDMAP_LLM_SOURCE === "dsh" ||
Boolean(process.env.MINDMAP_DSH_PROVIDER);
return {
configured,
source: viaDsh ? "dsh" : configured ? "env" : "none",
provider: process.env.MINDMAP_DSH_PROVIDER || undefined,
model:
process.env.MINDMAP_DSH_MODEL ||
process.env.LLM_MODEL ||
"deepseek-chat",
baseURL: process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || undefined,
};
}
function getModel() {
const apiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "missing";
const baseURL =
process.env.LLM_BASE_URL ||
process.env.OPENAI_BASE_URL ||
"https://api.deepseek.com/v1";
// When bridged through DSH OpenAI shim, model id is ignored by the shim;
// settings card provider/model win on the host side.
const modelId = process.env.LLM_MODEL || "deepseek-chat";
const provider = createOpenAI({ apiKey, baseURL });
return provider(modelId);
}
const OpZ = z
.object({
type: z.string().describe("Op discriminator: create_node | link | update_text | … (NOT `op`)"),
})
.passthrough();
export function createChatHandler(ctx: ToolContext) {
return async function handleChat(body: {
canvasId: string;
messages: { role: "user" | "assistant" | "system"; content: string }[];
}) {
const canvasId = body.canvasId;
const spatial = buildSpatialContext(ctx.store, canvasId);
const system = `你是 Mind Map 画布上的 AI 协作者。图是唯一事实源。
规则:
1. 先用 mindmap_get_spatial_context / mindmap_find 定位,禁止臆造节点 ID。找边用 searchScope=edge_label|edge_note。
2. find 返回 ambiguous=true 时必须向用户反问,不得自行挑选。
3. 快速档(riskProfile=fast):低风险用 mindmap_apply_direct(新建、改文本、关联、样式);删除/换父等用 mindmap_propose。
4. 严格档:一律 propose,等用户接受。
5. 禁止输出整棵树 JSON 覆盖;只提交增量 ops。每个 op 必须用字段 type(禁止用 op)。
6. 对话里先用自然语言说明你在做什么;工具会改图。
7. 用户若说「特朗普家族」等关系梳理:围绕焦点创建人物节点与 relation/hierarchy 边,边可带 label(如配偶、子女)。link 示例:{"type":"link","from":"...","to":"...","kind":"relation","label":"配偶"}。
8. 固定坐标用 set_pinned 或 create_node 带 pos;不确定时 mindmap_get_schema。
9. 节点属性:标题用 update_text;描述/多标签/多链接/图片URL/时间用 update_node_meta(patch);边用 update_edge_meta 或 set_edge_label。
10. 节点类型 stylePreset:default|card|title|decision|risk|note|muted(title 仅画布锚点,勿新建;card=新闻快照卡片,配图用 imageUrl、摘要用 note、来源用 links)。
11. 同级排序用 reorder:优先 {"type":"reorder","nodeId":"...","order":10};也可用 afterSiblingId(null=置顶)。
12. 折叠 set_collapsed 会隐藏该节点主父链下的全部子孙并重新布局;展开后恢复。
13. 问图:用 mindmap_query(neighbors|path|stats|orphans|missing_meta);答完可用 mindmap_highlight 高亮路径;细节用 mindmap_get_node / mindmap_get_edge。禁止编造图中没有的事实。
当前空间上下文(摘要):
${JSON.stringify(spatial, null, 0).slice(0, 6000)}
`;
const toolCtx: ToolContext = { ...ctx, defaultCanvasId: canvasId };
const result = streamText({
model: getModel(),
system,
messages: body.messages as CoreMessage[],
maxSteps: 10,
tools: {
mindmap_get_spatial_context: tool({
description: "获取选区/视口/外围簇等空间上下文",
parameters: z.object({ canvasId: z.string().optional() }),
execute: async (args) => runTool("mindmap_get_spatial_context", args, toolCtx),
}),
mindmap_find: tool({
description:
"检索节点或边;searchScope=text|note|tags|description|edge_label|edge_note|all;可能返回 ambiguous",
parameters: z.object({
query: z.string(),
searchScope: z
.enum([
"text",
"note",
"tags",
"description",
"edge_label",
"edge_note",
"all",
])
.optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_find", args, toolCtx),
}),
mindmap_query: tool({
description:
"图问答:neighbors|path|stats|orphans|missing_meta。path 含 paths[].edges;stats 含 longestPath/mostConnected",
parameters: z.object({
op: z.enum(["neighbors", "path", "stats", "orphans", "missing_meta"]),
node: z.string().optional(),
nodeId: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
depth: z.number().optional(),
maxHops: z.number().optional(),
direction: z.enum(["out", "in", "both"]).optional(),
edgeKinds: z.enum(["all", "hierarchy", "relation"]).optional(),
throughRelationNodes: z.boolean().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_query", args as Record<string, unknown>, toolCtx),
}),
mindmap_get_node: tool({
description: "读单个节点详情(description/note/tags/links)",
parameters: z.object({
nodeId: z.string(),
fields: z.array(z.enum(["all", "description", "note", "tags", "links"])).optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_node", args, toolCtx),
}),
mindmap_get_edge: tool({
description: "读单条边详情(label/weight/lineStyle/direction/note)",
parameters: z.object({
edgeId: z.string(),
fields: z.array(z.enum(["all", "label", "meta"])).optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_edge", args, toolCtx),
}),
mindmap_highlight: tool({
description: "在画布上高亮节点/边(临时,不改图);问答后可视化路径",
parameters: z.object({
nodeIds: z.array(z.string()).optional(),
edgeIds: z.array(z.string()).optional(),
ttlMs: z.number().optional(),
style: z
.object({
nodeColor: z.string().optional(),
nodeGlow: z.boolean().optional(),
edgeWidth: z.number().optional(),
edgeColor: z.string().optional(),
})
.optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_highlight", args as Record<string, unknown>, toolCtx),
}),
mindmap_get_subgraph: tool({
description: "读取以某节点为根的子图(depth<=3)",
parameters: z.object({
rootId: z.string().optional(),
depth: z.number().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_subgraph", args, toolCtx),
}),
mindmap_apply_direct: tool({
description: "快速档直接应用低风险 ops",
parameters: z.object({
ops: z.array(OpZ),
summary: z.string().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_apply_direct", args as Record<string, unknown>, toolCtx),
}),
mindmap_propose: tool({
description: "提交待确认提案(高风险或严格档)",
parameters: z.object({
ops: z.array(OpZ),
rationale: z.string(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_propose", args as Record<string, unknown>, toolCtx),
}),
mindmap_focus: tool({
description: "移动视觉焦点",
parameters: z.object({
nodeId: z.string(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_focus", args, toolCtx),
}),
},
});
return result;
};
}

View file

@ -0,0 +1,736 @@
import fs from "node:fs";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { cors } from "hono/cors";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Server } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";
import { SqliteGraphStore } from "@mind-map/graph-store";
import { coerceOpInput, OpSchema, type Op } from "@mind-map/shared";
import { listSkins } from "@mind-map/dream-skin";
import { GraphError } from "@mind-map/graph-core";
import { z } from "zod";
import {
createChatHandler,
llmConfigured,
llmStatus,
} from "./chat.js";
import {
listProposals,
runTool,
setActivity,
clientCanvasPayload,
finalizeAutoFit,
} from "./tools.js";
export type SessionBridge = {
prompt: (canvasId: string, text: string) => Promise<{ ok: boolean; sessionId?: string }>
history: (canvasId: string) => Promise<{ sessionId: string | null; messages: { role: string; content: string }[] }>
status: (canvasId?: string) => { ok: boolean; mode: string; bound?: boolean; sessionId?: string | null; agents?: boolean }
}
function bridgeBase() {
return (process.env.MINDMAP_DSH_BRIDGE || "").replace(/\/$/, "");
}
function internalToken() {
return process.env.MINDMAP_INTERNAL_TOKEN || "";
}
async function callDshBridge(path: string, init?: RequestInit) {
const base = bridgeBase();
if (!base) throw new Error("DSH bridge not configured");
const res = await fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
throw new Error(String(body.message || body.error || `bridge ${res.status}`));
}
return body;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const mindMapRoot = path.resolve(__dirname, "../../..");
export function loadDotEnv(root = mindMapRoot) {
try {
const envPath = path.join(root, ".env");
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
if (!m || line.trimStart().startsWith("#")) continue;
const key = m[1]!;
let val = m[2]!;
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] == null) process.env[key] = val;
}
} catch {
/* ignore */
}
}
export type MindMapServerOptions = {
port?: number;
bind?: string;
dataDir?: string;
root?: string;
/** When set by DSH plugin, overrides env-based LLM status messaging. */
llmSource?: "dsh" | "env" | "none";
/** Optional in-process session bridge (preferred over HTTP MINDMAP_DSH_BRIDGE). */
sessionBridge?: SessionBridge;
};
export type MindMapServerHandle = {
app: Hono;
store: SqliteGraphStore;
port: number;
bind: string;
dbPath: string;
close: () => Promise<void>;
};
export function startMindMapServer(
options: MindMapServerOptions = {},
): MindMapServerHandle {
const root = options.root ?? mindMapRoot;
loadDotEnv(root);
const port = Number(options.port ?? process.env.PORT ?? 17890);
const bind = options.bind ?? process.env.BIND ?? "127.0.0.1";
const dataDir = options.dataDir ?? process.env.DATA_DIR ?? path.join(root, "data");
const dbPath = path.join(dataDir, "mindmap.sqlite");
const store = new SqliteGraphStore(dbPath);
const app = new Hono();
app.use("*", cors({ origin: "*" }));
const sockets = new Set<WebSocket>();
function broadcast(payload: unknown) {
const data = JSON.stringify(payload);
for (const ws of sockets) {
if (ws.readyState === ws.OPEN) ws.send(data);
}
}
app.get("/health", (c) =>
c.json({ ok: true, port, bind, dbPath, skins: listSkins().map((s) => s.id) }),
);
app.get("/api/skins", (c) => c.json({ skins: listSkins() }));
app.get("/api/canvases", (c) =>
c.json({
canvases: store.listCanvases().map((x) => ({
id: x.id,
title: x.title,
focusNodeId: x.focusNodeId === x.anchorNodeId ? null : x.focusNodeId,
viewport: x.viewport,
prefs: x.prefs,
rev: x.rev,
agentMode: x.agentMode,
createdAt: x.createdAt,
updatedAt: x.updatedAt,
})),
}),
);
app.post("/api/canvases", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
title?: string;
rootText?: string;
};
const g = store.createCanvas(body.title, body.rootText);
broadcast({ type: "canvas/created", canvasId: g.canvas.id });
return c.json(clientCanvasPayload(store, g.canvas.id));
});
app.get("/api/canvases/:id", (c) => {
const id = c.req.param("id");
try {
return c.json(clientCanvasPayload(store, id));
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.patch("/api/canvases/:id", async (c) => {
const id = c.req.param("id");
const body = (await c.req.json()) as {
title?: string;
viewport?: { x: number; y: number; zoom: number };
};
if (body.title) store.renameCanvas(id, body.title);
if (body.viewport) store.updateViewport(id, body.viewport);
return c.json({ canvas: store.loadCanvas(id).canvas });
});
app.delete("/api/canvases/:id", (c) => {
const id = c.req.param("id");
store.hardDeleteCanvas(id);
broadcast({ type: "canvas/deleted", canvasId: id });
return c.json({ ok: true });
});
app.post("/api/canvases/:id/ops", async (c) => {
const id = c.req.param("id");
const body = await c.req.json();
const coercedOps = Array.isArray(body?.ops)
? body.ops.map((o: unknown) => coerceOpInput(o))
: body?.ops;
const parsed = z
.object({
ops: z.array(OpSchema),
origin: z.enum(["user", "ai"]).default("user"),
summary: z.string().optional(),
})
.safeParse({ ...body, ops: coercedOps });
if (!parsed.success) {
return c.json(
{
ok: false,
error: "invalid_op",
message:
"Each op needs field `type` (not `op`). Valid: create_node, link, update_text, set_pinned, …",
details: parsed.error.flatten(),
},
400,
);
}
try {
const result = store.applyChangeSet(id, parsed.data.ops as Op[], {
origin: parsed.data.origin,
summary: parsed.data.summary,
});
const fitViewport =
result.autoFitPadding != null
? finalizeAutoFit(store, id, result.autoFitPadding)
: null;
const payload = clientCanvasPayload(store, id);
const preserveUnpinned = parsed.data.ops.every((o) => o.type === "set_pinned");
broadcast({
type: "canvas/event",
canvasId: id,
changeSetId: result.changeSetId,
rev: result.rev,
preserveUnpinned,
affectedNodeIds: result.affectedNodeIds.filter(
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
),
fitViewport: fitViewport ?? undefined,
});
return c.json({
ok: true,
changeSetId: result.changeSetId,
rev: result.rev,
affectedNodeIds: result.affectedNodeIds.filter(
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
),
fitViewport: fitViewport ?? undefined,
...payload,
});
} catch (e) {
if (e instanceof GraphError) {
return c.json({ ok: false, error: e.code, message: e.message }, 409);
}
return c.json({ ok: false, error: "invalid_op", message: String(e) }, 400);
}
});
app.post("/api/canvases/:id/undo", async (c) => {
const canvasId = c.req.param("id");
const body = (await c.req.json().catch(() => ({}))) as {
changeSetId?: string;
};
try {
let changeSetId = body.changeSetId;
if (!changeSetId) {
changeSetId = store.latestUndoableChangeSetId(canvasId) ?? undefined;
}
if (!changeSetId) {
return c.json(
{ ok: false, error: "nothing_to_undo", message: "No undoable change set" },
400,
);
}
const result = store.undoChangeSet(changeSetId);
const payload = clientCanvasPayload(store, canvasId);
broadcast({
type: "canvas/event",
canvasId,
changeSetId: result.changeSetId,
rev: result.rev,
});
return c.json({
ok: true,
undoneChangeSetId: changeSetId,
changeSetId: result.changeSetId,
rev: result.rev,
...payload,
});
} catch (e) {
return c.json({ ok: false, error: "undo_failed", message: String(e) }, 400);
}
});
app.get("/api/canvases/:id/search", (c) => {
const q = c.req.query("q") ?? "";
const scopeRaw = c.req.query("scope") ?? "text";
const scope =
scopeRaw === "note" ||
scopeRaw === "tags" ||
scopeRaw === "description" ||
scopeRaw === "edge_label" ||
scopeRaw === "edge_note" ||
scopeRaw === "all"
? scopeRaw
: "text";
return c.json({ hits: store.search(c.req.param("id"), q, 20, scope) });
});
app.get("/api/canvases/:id/trash", (c) => {
try {
return c.json({ nodes: store.listDeletedNodes(c.req.param("id")) });
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.post("/api/canvases/:id/trash/:nodeId/restore", (c) => {
try {
const result = store.restoreNode(c.req.param("id"), c.req.param("nodeId"));
broadcast({
type: "canvas/event",
canvasId: c.req.param("id"),
changeSetId: result.changeSetId,
});
return c.json({ ok: true, ...result });
} catch (e) {
return c.json({ ok: false, message: String(e) }, 400);
}
});
app.post("/api/canvases/:id/activity", async (c) => {
const id = c.req.param("id");
const body = (await c.req.json()) as {
selectionIds?: string[];
focusNodeId?: string | null;
viewport?: { x: number; y: number; zoom: number };
};
setActivity({
canvasId: id,
selectionIds: body.selectionIds ?? [],
focusNodeId: body.focusNodeId ?? null,
viewport: body.viewport ?? { x: 0, y: 0, zoom: 1 },
updatedAt: Date.now(),
});
return c.json({ ok: true });
});
app.get("/api/canvases/:id/proposals", (c) => {
return c.json({ proposals: listProposals(c.req.param("id")) });
});
app.post("/api/canvases/:id/proposals/:pid/accept", async (c) => {
const result = await runTool(
"mindmap_accept_proposal",
{ proposalId: c.req.param("pid"), canvasId: c.req.param("id") },
{ store, broadcast, defaultCanvasId: c.req.param("id") },
);
return c.json(result);
});
app.post("/api/canvases/:id/proposals/:pid/reject", async (c) => {
const result = await runTool(
"mindmap_reject_proposal",
{ proposalId: c.req.param("pid") },
{ store, broadcast, defaultCanvasId: c.req.param("id") },
);
return c.json(result);
});
app.get("/api/canvases/:id/export.json", (c) => {
try {
const snap = store.getSnapshot(c.req.param("id"));
return c.json(snap);
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.post("/api/tools/:name", async (c) => {
const name = c.req.param("name");
const args = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
try {
const result = await runTool(name, args, { store, broadcast });
return c.json(result);
} catch (e) {
return c.json(
{ ok: false, error: "tool_error", message: String(e) },
400,
);
}
});
const handleChat = createChatHandler({ store, broadcast });
async function sessionStatus(canvasId?: string) {
if (options.sessionBridge) {
return { ...options.sessionBridge.status(canvasId), canvasUrl: `http://${bind}:${port}` };
}
if (bridgeBase()) {
const q = canvasId ? `?canvasId=${encodeURIComponent(canvasId)}` : "";
return callDshBridge(`/status${q}`);
}
return {
ok: true,
mode: llmConfigured() ? "legacy-env-llm" : "unconfigured",
bound: false,
sessionId: null,
agents: false,
message: "未连接 DSH:请通过 dsh-mind-map 插件启动,对话将镜像 DSH 会话",
};
}
app.get("/api/session/status", async (c) => {
try {
const canvasId = c.req.query("canvasId") || undefined;
return c.json(await sessionStatus(canvasId));
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/session/prompt", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
canvasId?: string;
text?: string;
};
if (!body.canvasId || !body.text?.trim()) {
return c.json({ ok: false, message: "canvasId and text required" }, 400);
}
try {
if (options.sessionBridge) {
const result = await options.sessionBridge.prompt(body.canvasId, body.text);
return c.json(result);
}
if (bridgeBase()) {
const result = await callDshBridge("/prompt", {
method: "POST",
body: JSON.stringify({ canvasId: body.canvasId, text: body.text }),
});
return c.json(result);
}
return c.json(
{
ok: false,
error: "dsh_bridge_missing",
message: "对话需经 DSH 会话。请用 dsh-mind-map 插件启动画布服务。",
},
503,
);
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.get("/api/session/history", async (c) => {
const canvasId = c.req.query("canvasId");
if (!canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
try {
if (options.sessionBridge) {
const hist = await options.sessionBridge.history(canvasId);
return c.json({ ok: true, ...hist });
}
if (bridgeBase()) {
const hist = await callDshBridge(
`/history?canvasId=${encodeURIComponent(canvasId)}`,
);
return c.json(hist);
}
return c.json({ ok: true, sessionId: null, messages: [] });
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/session/ensure", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { canvasId?: string };
if (!body.canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
try {
if (options.sessionBridge) {
// ensure via prompt path's ensureBinding is private; status+history enough
const st = options.sessionBridge.status(body.canvasId);
if (st.bound) return c.json({ ok: true, sessionId: st.sessionId });
}
if (bridgeBase()) {
const result = await callDshBridge("/ensure", {
method: "POST",
body: JSON.stringify({ canvasId: body.canvasId }),
});
return c.json(result);
}
return c.json({ ok: false, message: "DSH bridge missing" }, 503);
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/internal/session-event", async (c) => {
const token = c.req.header("x-mindmap-token") || "";
const expected = internalToken();
if (expected && token !== expected) {
return c.json({ ok: false, error: "forbidden" }, 403);
}
const body = (await c.req.json().catch(() => ({}))) as {
canvasId?: string;
mirror?: Record<string, unknown>;
};
if (!body.canvasId || !body.mirror) {
return c.json({ ok: false, message: "canvasId and mirror required" }, 400);
}
broadcast({
type: "session/mirror",
canvasId: body.canvasId,
mirror: body.mirror,
});
return c.json({ ok: true });
});
const uploadsDir = path.join(dataDir, "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
app.post("/api/uploads", async (c) => {
const body = (await c.req.json().catch(() => null)) as {
dataBase64?: string;
mimeType?: string;
filename?: string;
} | null;
const raw = String(body?.dataBase64 || "").trim();
if (!raw) {
return c.json({ ok: false, message: "dataBase64 required" }, 400);
}
let b64 = raw;
let mime = String(body?.mimeType || "").trim().toLowerCase();
const dataUrl = /^data:([^;]+);base64,(.+)$/i.exec(raw);
if (dataUrl) {
mime = dataUrl[1]!.toLowerCase();
b64 = dataUrl[2]!;
}
const allowed: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
};
const ext = allowed[mime];
if (!ext) {
return c.json(
{
ok: false,
message: "仅支持 jpeg/png/webp/gif",
},
400,
);
}
let buf: Buffer;
try {
buf = Buffer.from(b64, "base64");
} catch {
return c.json({ ok: false, message: "invalid base64" }, 400);
}
if (!buf.length || buf.length > 6 * 1024 * 1024) {
return c.json({ ok: false, message: "图片需小于 6MB(压缩后)" }, 400);
}
const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
const name = `${id}.${ext}`;
fs.writeFileSync(path.join(uploadsDir, name), buf);
const url = `/uploads/${name}`;
return c.json({ ok: true, url, mimeType: mime, bytes: buf.length });
});
app.get("/uploads/:name", (c) => {
const name = c.req.param("name");
if (!/^[\w.-]+\.(jpe?g|png|webp|gif)$/i.test(name)) {
return c.notFound();
}
const fp = path.join(uploadsDir, name);
if (!fs.existsSync(fp)) return c.notFound();
const lower = name.toLowerCase();
const mime = lower.endsWith(".png")
? "image/png"
: lower.endsWith(".webp")
? "image/webp"
: lower.endsWith(".gif")
? "image/gif"
: "image/jpeg";
const data = fs.readFileSync(fp);
return new Response(data, {
status: 200,
headers: {
"Content-Type": mime,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
});
// Legacy env-LLM path kept only as offline fallback; prefer DSH session.
app.post("/api/chat", async (c) => {
if (bridgeBase() || options.sessionBridge) {
return c.json(
{
ok: false,
error: "use_session_prompt",
message: "请使用 /api/session/prompt(DSH 会话镜像)",
},
400,
);
}
if (!llmConfigured()) {
return c.json(
{
ok: false,
error: "llm_not_configured",
message:
"请通过 DSH 插件 dsh-mind-map 使用会话对话;独立调试才需要 LLM_API_KEY",
},
503,
);
}
const body = (await c.req.json()) as {
canvasId: string;
messages: { role: "user" | "assistant" | "system"; content: string }[];
};
if (!body.canvasId || !body.messages?.length) {
return c.json({ ok: false, message: "canvasId and messages required" }, 400);
}
try {
const result = await handleChat(body);
return result.toDataStreamResponse();
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.get("/api/llm/status", async (c) => {
try {
const st = await sessionStatus();
const dshOk = st.mode === "dsh-session" || Boolean(bridgeBase()) || Boolean(options.sessionBridge);
return c.json({
...llmStatus(options.llmSource ?? (dshOk ? "dsh" : undefined)),
configured: dshOk || llmConfigured(),
session: st,
});
} catch {
return c.json({
...llmStatus(options.llmSource),
configured: llmConfigured(),
});
}
});
// Static UI: cwd is mind-map root when launched by the plugin.
const webDistRel = "apps/web/dist";
const webDistAbs = path.join(root, "apps/web/dist");
if (fs.existsSync(webDistAbs) || fs.existsSync(path.join(process.cwd(), webDistRel))) {
app.use(
"/*",
serveStatic({
root: fs.existsSync(webDistAbs) ? webDistAbs : webDistRel,
index: "index.html",
}),
);
// SPA fallback (never swallow API / health)
app.get("*", async (c) => {
const p = c.req.path
if (p.startsWith("/api") || p === "/health" || p.startsWith("/ws") || p.startsWith("/uploads")) {
return c.notFound()
}
const indexPath = path.join(
fs.existsSync(webDistAbs) ? webDistAbs : path.join(process.cwd(), webDistRel),
"index.html",
);
if (!fs.existsSync(indexPath)) {
return c.text(
"Mind Map UI not built. From mind-map repo run: pnpm --filter @mind-map/web build",
503,
);
}
return c.html(fs.readFileSync(indexPath, "utf8"));
});
} else {
app.get("/", (c) =>
c.text(
"Mind Map UI missing (apps/web/dist). Run: pnpm --filter @mind-map/web build",
503,
),
);
}
const server = serve({ fetch: app.fetch, port, hostname: bind }, (info) => {
console.log(`[mind-map] http://${info.address}:${info.port}`);
console.log(`[mind-map] db ${dbPath}`);
}) as unknown as Server;
server.on("error", (err: NodeJS.ErrnoException) => {
console.error(`[mind-map] listen error: ${err.code || err.message}`);
if (err.code === "EADDRINUSE") {
console.error(
`[mind-map] ${bind}:${port} is busy — stop the old process or change the port in DSH settings`,
);
}
process.exitCode = 1;
try {
store.close();
} catch {
/* ignore */
}
process.exit(1);
});
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("error", (err) => {
console.error(`[mind-map] ws error: ${String(err)}`);
});
wss.on("connection", (ws) => {
sockets.add(ws);
ws.send(JSON.stringify({ type: "hello", ok: true }));
ws.on("message", (raw) => {
try {
const msg = JSON.parse(String(raw)) as { type?: string };
if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
} catch {
/* ignore */
}
});
ws.on("close", () => sockets.delete(ws));
});
return {
app,
store,
port,
bind,
dbPath,
close: async () => {
await new Promise<void>((resolve) => {
wss.close();
server.close(() => resolve());
});
store.close();
},
};
}

3
apps/server/src/index.ts Normal file
View file

@ -0,0 +1,3 @@
import { startMindMapServer } from "./create-server.js";
startMindMapServer();

2005
apps/server/src/tools.ts Normal file

File diff suppressed because it is too large Load diff

16
apps/server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*"],
"references": [
{ "path": "../../packages/shared" },
{ "path": "../../packages/graph-core" },
{ "path": "../../packages/graph-store" },
{ "path": "../../packages/layout-engine" },
{ "path": "../../packages/dream-skin" }
]
}

View file

@ -0,0 +1 @@
{"root":["./src/chat.ts","./src/create-server.ts","./src/index.ts","./src/tools.ts"],"version":"5.9.3"}

12
apps/web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind Map</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
apps/web/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@mind-map/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mind-map/dream-skin": "workspace:*",
"@mind-map/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
t = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx").read_text(encoding="utf-8")
print("quoted ??", re.findall(r'"\?{2,}"', t))
print("选择" in t, "单点" in t, "撤销" in t)

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
p = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx")
t = p.read_text(encoding="utf-8")
for m in re.finditer(r'.{0,60}"\?{2,}".{0,40}', t):
print(repr(m.group(0)))
# fix leftover ??? for create_node
if ': { type: "create_node", text: "???" }' in t:
t = t.replace(': { type: "create_node", text: "???" }', ': { type: "create_node", text: "新节点" }')
p.write_text(t, encoding="utf-8", newline="\n")
print("fixed leftover")
print("quoted left", re.findall(r'"\?{2,}"', t))
print("cjk count", len(re.findall(r"[\u4e00-\u9fff]+", t)))

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from pathlib import Path
t = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx").read_text(encoding="utf-8")
i = t.find('dragScope === "node"')
print("=== drag node ===")
print(repr(t[i - 80 : i + 320]))
print("=== hint ===")
j = t.find("canvas-hint")
print(repr(t[j : j + 400]))
print("=== remaining ?? ===")
import re
for m in re.finditer(r".{0,40}\?{2,}.{0,40}", t):
print(repr(m.group(0)))

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
src = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx")
dist = Path(r"d:\project\chatgpt\mind-map\apps\web\dist\assets")
b = src.read_bytes()
print("src has 选择 bytes:", "选择".encode("utf-8") in b)
print("src has literal ?? button:", bool(re.search(rb">\s*\?\?\s*<", b)))
# show hex around select label
idx = b.find(b'["select"')
print("src select snippet:", b[idx : idx + 30])
for f in dist.glob("index-*.js"):
jb = f.read_bytes()
print(f.name, "size", len(jb))
print(" has 选择:", "选择".encode("utf-8") in jb)
print(" has [\"select\",\"??\":", b'["select","??"' in jb or b'["select", "??"' in jb)
m = re.search(rb'\["select",\s*"[^"]*"', jb)
if m:
print(" select match:", m.group(0))
m2 = re.search(rb'\["pan",\s*"[^"]*"', jb)
if m2:
print(" pan match:", m2.group(0))

View file

@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
"""Restore CJK strings in TopologyCanvas.tsx corrupted to '?' / '??'."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
# comments
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place \?pin", "// Free place → pin", label="free place comment")
# create defaults
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
# toolbar: back
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r'\1返回画布列表\2返回\3',
label="back to roster",
)
# tool modes
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
# drag scope
sub(
r'(className=\{`canvas-tool-btn\$\{dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r'\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2单点\3',
label="drag node",
)
sub(
r'(className=\{`canvas-tool-btn\$\{dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r'\1整枝拖拽:节点与子孙一起移动 (B)\2整枝\3',
label="drag branch",
)
# bubble / card / unpin
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r'\1气泡视图:精简标题\2',
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r'\1气泡\2',
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r'\1卡片视图:展开属性面板\2',
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r'\1卡片\2取消钉住:让选中节点重新参与自动布局\3',
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r'\1解钉\2撤销\3',
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r'\1撤销\2',
label="undo label",
)
# aria-labels for toolbars
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r'\1画布工具\2',
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r'\1视图控制\2',
label="controls aria",
)
# controls titles
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
# handles
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r'\1添加左侧子主题\2',
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r'\1添加右侧子主题\2',
label="handle right title",
)
# hint footer
sub(
r'''\{locked
\? "\?+"
: dragScope === "node"
\? "\?+"
: "\?+"\}''',
'''{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}''',
label="canvas hint",
)
# context menu labels if any remain
sub(
r'label: "\?+"',
'label: "关联"',
count=1,
label="label leftover",
)
remaining = len(re.findall(r"\?{2,}", text))
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("remaining ?? sequences:", remaining)
print("cjk tokens:", cjk)
print("misses:", len(misses))
if misses:
raise SystemExit(1)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)

View file

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""Restore remaining CJK in TopologyCanvas.tsx (context-anchored)."""
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
replacements = [
("// iframe ??????????? Tab ??", "// iframe 内无法全屏时,通知父页 Tab 全屏"),
('{ type: "create_node", text: "???", parentId: parent }', '{ type: "create_node", text: "新节点", parentId: parent }'),
(': { type: "create_node", text: "???" },', ': { type: "create_node", text: "新节点" },'),
(
' text: "????",\n parentId: selectedIds[0],\n sidePref: -1,',
' text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1,',
),
(
' text: "????",\n parentId: selectedIds[0],\n sidePref: 1,',
' text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1,',
),
(
' text: "????",\n parentId: parentOfSel,',
' text: "兄弟节点",\n parentId: parentOfSel,',
),
('label: "??",', 'label: "关联",'),
("// Free place ?pin every dragged id", "// Free place → pin every dragged id"),
(
' text: "???",\n ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),',
' text: "新节点",\n ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),',
),
('aria-label="????"\n onPointerDown={(e) => e.stopPropagation()}\n >\n {embed ? (', 'aria-label="画布工具"\n onPointerDown={(e) => e.stopPropagation()}\n >\n {embed ? ('),
('title="??????"\n onClick={requestBackToRoster}\n >\n ??\n </button>', 'title="返回画布列表"\n onClick={requestBackToRoster}\n >\n 返回\n </button>'),
('["select", "??", "V"]', '["select", "选择", "V"]'),
('["pan", "??", "H"]', '["pan", "平移", "H"]'),
('["link", "??", "C"]', '["link", "连线", "C"]'),
(
'title="???????????? (N)??? Shift ??????"\n aria-pressed={dragScope === "node"}\n onClick={() => setDragScope("node")}\n >\n ??\n </button>',
'title="单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点"\n aria-pressed={dragScope === "node"}\n onClick={() => setDragScope("node")}\n >\n 单点\n </button>',
),
(
'title="?????????????? (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n ??\n </button>',
'title="整枝拖拽:节点与子孙一起移动 (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n 整枝\n </button>',
),
(
'title="?????????????"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n ??\n </button>',
'title="气泡视图:精简标题"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n 气泡\n </button>',
),
(
'title="??????????????"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n ??\n </button>',
'title="卡片视图:展开属性面板"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n 卡片\n </button>',
),
(
'title="??????????????????"\n disabled={\n locked ||\n !selectedIds[0] ||\n !nodes.find((n) => n.id === selectedIds[0])?.pinned\n }\n onClick={() => {\n if (!selectedIds[0]) return;\n void applyOps([\n { type: "set_pinned", nodeId: selectedIds[0], pinned: false },\n ]);\n }}\n >\n ??\n </button>',
'title="取消钉住:让选中节点重新参与自动布局"\n disabled={\n locked ||\n !selectedIds[0] ||\n !nodes.find((n) => n.id === selectedIds[0])?.pinned\n }\n onClick={() => {\n if (!selectedIds[0]) return;\n void applyOps([\n { type: "set_pinned", nodeId: selectedIds[0], pinned: false },\n ]);\n }}\n >\n 解钉\n </button>',
),
(
'title="??"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n ??\n </button>',
'title="撤销"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n 撤销\n </button>',
),
(
'aria-label="????"\n onPointerDown={(e) => e.stopPropagation()}\n >\n <button\n type="button"\n className="canvas-controls__btn"\n title={fullscreen ? "????" : "??"}\n aria-label={fullscreen ? "????" : "??"}',
'aria-label="视图控制"\n onPointerDown={(e) => e.stopPropagation()}\n >\n <button\n type="button"\n className="canvas-controls__btn"\n title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
),
(
'title="?? (+)"\n aria-label="??"',
'title="放大 (+)"\n aria-label="放大"',
),
(
'title="?? (-)"\n aria-label="??"',
'title="缩小 (-)"\n aria-label="缩小"',
),
(
'title="???? (F)"\n aria-label="????"',
'title="适应画布 (F)"\n aria-label="适应画布"',
),
(
'title={locked ? "???? (L)" : "???? (L)"}\n aria-label={locked ? "??" : "??"}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
),
(
'const text = side < 0 ? "????" : "????";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
),
(
'className="node-handle node-handle--left"\n style={{ left: cx - hw - 14, top: cy - 10 }}\n title="????????"',
'className="node-handle node-handle--left"\n style={{ left: cx - hw - 14, top: cy - 10 }}\n title="添加左侧子主题"',
),
(
'className="node-handle node-handle--right"\n style={{ left: cx + hw - 2, top: cy - 10 }}\n title="????????"',
'className="node-handle node-handle--right"\n style={{ left: cx + hw - 2, top: cy - 10 }}\n title="添加右侧子主题"',
),
(
'{locked\n ? "??? ? ???/??/????"\n : dragScope === "node"\n ? "??? ? ?????? ? Shift ???? ? Esc ??"\n : "??? ? ??? ? ???? ? ?????? ? N ??? ? Esc ??"}',
'{locked\n ? "已锁定 · 仅浏览/缩放/平移"\n : dragScope === "node"\n ? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"\n : "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}',
),
]
miss = []
for old, new in replacements:
if old not in text:
miss.append(old[:70])
print("MISS:", old[:70].replace("\n", "\\n"))
else:
text = text.replace(old, new, 1)
print("OK:", old[:50].replace("\n", "\\n"))
import re
# leftover UI ?? (not ?? nullish coalescing)
# count Chinese question-mark runs that are quoted UI strings
ui_q = re.findall(r'"\?{2,}"', text)
print("quoted ?? left:", ui_q)
print("misses:", len(miss))
if miss:
raise SystemExit(1)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)

View file

@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""Restore all CJK in TopologyCanvas.tsx after encoding corruption."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
# comments
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place .+?pin every dragged id", "// Free place → pin every dragged id", label="free place comment")
# placeholders used in draw / create
sub(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed wrap")
sub(r'(n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed text")
# create defaults
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
# toolbar: back
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r"\1返回画布列表\2返回\3",
label="back to roster",
)
# tool modes
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
# drag scope — title may contain latin (N)/(B)/Shift so match loosely
sub(
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r"\1单点\2",
label="drag node label",
)
sub(
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2",
label="drag node title",
)
sub(
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r"\1整枝\2",
label="drag branch label",
)
sub(
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1整枝拖拽:节点与子孙一起移动 (B)\2",
label="drag branch title",
)
# bubble / card / unpin
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r"\1气泡视图:精简标题\2",
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r"\1气泡\2",
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r"\1卡片视图:展开属性面板\2",
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r"\1卡片\2取消钉住:让选中节点重新参与自动布局\3",
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r"\1解钉\2撤销\3",
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r"\1撤销\2",
label="undo label",
)
# aria-labels for toolbars
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r"\1画布工具\2",
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r"\1视图控制\2",
label="controls aria",
)
# controls titles
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
# handles
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加左侧子主题\2",
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加右侧子主题\2",
label="handle right title",
)
# hint footer — allow · and mixed ?
sub(
r'''\{locked
\? "[^"]*"
: dragScope === "node"
\? "[^"]*"
: "[^"]*"\}''',
'''{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}''',
label="canvas hint",
)
# draw-sheet placeholders already Chinese in other files
remaining_ui = re.findall(r'"\?{2,}"', text)
# also button bodies that are only ??
remaining_btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("quoted ?? left:", remaining_ui)
print("button ?? left:", len(remaining_btn))
print("cjk tokens:", cjk)
print("misses:", len(misses))
for m in misses:
print(" -", m)
# Always write — partial fix is better than none; report leftovers
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)
if remaining_ui or remaining_btn or misses:
raise SystemExit(2)

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
t = p.read_text(encoding="utf-8")
pairs = [
(
'title="???????????? (N)??? Shift ??????"',
'title="单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点"',
),
(
'title="?????????????? (B)"',
'title="整枝拖拽:节点与子孙一起移动 (B)"',
),
]
for old, new in pairs:
if old not in t:
raise SystemExit(f"missing: {old}")
t = t.replace(old, new, 1)
print("fixed:", new[:40])
p.write_text(t, encoding="utf-8", newline="\n")
print("done")

View file

@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""Restore CJK in TopologyCanvas.tsx; tolerate latin1/broken middot encoding."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
raw = p.read_bytes()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("latin1")
# Normalize stray CP1252/latin1 middot into a matchable char
text = text.replace("\xb7", "·")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place .+?pin every dragged id", "// Free place → pin every dragged id", label="free place comment")
sub(r"// Contain: \?+", "// Contain: 等比例完整放入框内,不裁切", label="contain comment")
sub(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed wrap")
sub(r'(n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed text")
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r"\1返回画布列表\2返回\3",
label="back to roster",
)
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
sub(
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r"\1单点\2",
label="drag node label",
)
sub(
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2",
label="drag node title",
)
sub(
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r"\1整枝\2",
label="drag branch label",
)
sub(
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1整枝拖拽:节点与子孙一起移动 (B)\2",
label="drag branch title",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r"\1气泡视图:精简标题\2",
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r"\1气泡\2",
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r"\1卡片视图:展开属性面板\2",
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r"\1卡片\2取消钉住:让选中节点重新参与自动布局\3",
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r"\1解钉\2撤销\3",
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r"\1撤销\2",
label="undo label",
)
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r"\1画布工具\2",
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r"\1视图控制\2",
label="controls aria",
)
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加左侧子主题\2",
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加右侧子主题\2",
label="handle right title",
)
sub(
r"""\{locked
\? "[^"]*"
: dragScope === "node"
\? "[^"]*"
: "[^"]*"\}""",
"""{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}""",
label="canvas hint",
)
remaining_ui = re.findall(r'"\?{2,}"', text)
remaining_btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("quoted ?? left:", remaining_ui)
print("button ?? left:", len(remaining_btn))
print("cjk tokens:", cjk)
print("misses:", len(misses))
for m in misses:
print(" -", m)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)
if remaining_ui or remaining_btn or misses:
raise SystemExit(2)
print("SUCCESS")

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from pathlib import Path
d = Path(__file__).resolve().parents[1] / "dist" / "assets"
js_path = next(d.glob("index-*.js"))
js = js_path.read_text(encoding="utf-8")
needles = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"]
print(js_path.name)
print({n: (n in js) for n in needles})
print("corrupt select:", '["select","??"' in js or '["select", "??"' in js)

View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
t = p.read_text(encoding="utf-8")
print("utf8 ok, cjk tokens:", len(re.findall(r"[\u4e00-\u9fff]+", t)))
print("quoted ??:", re.findall(r'"\?{2,}"', t))
for i, line in enumerate(t.splitlines(), 1):
if "title=" in line and ("(N)" in line or "(B)" in line or "选择" in line or "气泡" in line):
print(f"{i}: {line.strip()}")
# toolbar labels
for needle in ("选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"):
print(needle, "OK" if needle in t else "MISSING")

346
apps/web/src/App.tsx Normal file
View file

@ -0,0 +1,346 @@
import { useEffect, useMemo } from "react";
import { NodeInspector } from "./NodeInspector";
import { applyThemeFromQuery } from "./system-theme";
import { TopologyCanvas } from "./TopologyCanvas";
import { useAppStore } from "./store";
function readQuery() {
try {
return new URLSearchParams(window.location.search);
} catch {
return new URLSearchParams();
}
}
export function App() {
const query = useMemo(() => readQuery(), []);
const embed = query.get("embed") === "1" || query.get("embed") === "true";
const queryCanvasId = query.get("canvasId") || "";
const {
canvases,
canvasId,
canvas,
nodes,
selectedIds,
leftCollapsed,
loadCanvases,
createCanvas,
openCanvas,
undo,
setCollapsedPanels,
setPrefs,
applyOps,
trash,
restoreFromTrash,
} = useAppStore();
useEffect(() => {
applyThemeFromQuery();
}, []);
useEffect(() => {
void (async () => {
await loadCanvases();
if (queryCanvasId) {
await openCanvas(queryCanvasId);
return;
}
if (!useAppStore.getState().canvasId) {
await createCanvas("未命名画布");
}
})();
}, [loadCanvases, createCanvas, openCanvas, queryCanvasId]);
useEffect(() => {
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`;
let ws: WebSocket | null = null;
let closed = false;
let retryTimer: number | undefined;
let refreshTimer: number | undefined;
let retryMs = 800;
const scheduleRefresh = (id: string, preserveUnpinned?: boolean) => {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(async () => {
if (useAppStore.getState().canvasId !== id) return;
try {
const data = await fetch(`/api/canvases/${id}`).then((r) => r.json()) as {
canvas: import("@mind-map/shared").CanvasMeta;
nodes: import("@mind-map/shared").MindNode[];
edges: import("@mind-map/shared").MindEdge[];
positions: import("./store").Positions;
};
useAppStore.getState().applyCanvasPayload(data, { preserveUnpinned });
} catch {
void useAppStore.getState().openCanvas(id);
}
}, 120);
};
const connect = () => {
if (closed) return;
try {
ws = new WebSocket(wsUrl);
} catch {
retryTimer = window.setTimeout(connect, retryMs);
retryMs = Math.min(8000, retryMs * 1.6);
return;
}
ws.onopen = () => {
retryMs = 800;
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as {
type?: string;
canvasId?: string;
preserveUnpinned?: boolean;
nodeIds?: string[];
edgeIds?: string[];
style?: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
};
ttlMs?: number;
fitViewport?: { x: number; y: number; zoom: number };
mirror?: {
kind: "user" | "assistant" | "text-delta" | "tool" | "turn-end";
text?: string;
name?: string;
callId?: string;
sessionId?: string;
reason?: unknown;
};
};
if (
msg.type === "canvas/event" &&
msg.canvasId &&
msg.canvasId === useAppStore.getState().canvasId
) {
if (msg.fitViewport) {
useAppStore.getState().requestFitViewport(msg.fitViewport);
}
scheduleRefresh(msg.canvasId, msg.preserveUnpinned === true);
}
if (
msg.type === "canvas/highlight" &&
msg.canvasId &&
msg.canvasId === useAppStore.getState().canvasId
) {
const nodeIds = Array.isArray(msg.nodeIds) ? msg.nodeIds : [];
const edgeIds = Array.isArray(msg.edgeIds) ? msg.edgeIds : [];
useAppStore.getState().flashHighlight({
nodeIds,
edgeIds,
style:
msg.style && typeof msg.style === "object" ? msg.style : null,
ttlMs:
typeof msg.ttlMs === "number"
? msg.ttlMs
: edgeIds.length
? 5000
: 2200,
});
}
} catch {
/* ignore */
}
};
ws.onclose = () => {
if (closed) return;
retryTimer = window.setTimeout(connect, retryMs);
retryMs = Math.min(8000, retryMs * 1.6);
};
ws.onerror = () => {
try {
ws?.close();
} catch {
/* ignore */
}
};
};
connect();
return () => {
closed = true;
window.clearTimeout(retryTimer);
window.clearTimeout(refreshTimer);
try {
ws?.close();
} catch {
/* ignore */
}
};
}, []);
useEffect(() => {
const id = canvasId;
if (!id) return;
const t = window.setTimeout(() => {
void fetch(`/api/canvases/${id}/activity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
selectionIds: selectedIds,
focusNodeId: canvas?.focusNodeId ?? null,
viewport: canvas?.viewport ?? { x: 0, y: 0, zoom: 1 },
}),
});
}, 200);
return () => window.clearTimeout(t);
}, [canvasId, selectedIds, canvas?.focusNodeId, canvas?.viewport]);
const focusId = canvas?.focusNodeId;
const outline = nodes.filter((n) => !n.deletedAt);
if (embed) {
return (
<div className="app-shell embed">
<div className="main embed-main">
<TopologyCanvas />
</div>
</div>
);
}
return (
<div className="app-shell">
<header className="topbar">
<div className="brand">Mind Map</div>
<button type="button" onClick={() => setCollapsedPanels("left", !leftCollapsed)}>
{leftCollapsed ? "目录" : "收目录"}
</button>
<button type="button" onClick={() => void createCanvas()}>
新建
</button>
<button type="button" onClick={() => void undo()} disabled={!useAppStore.getState().lastChangeSetId}>
撤销
</button>
<button
type="button"
onClick={() =>
void setPrefs({
density: canvas?.prefs.density === "compact" ? "comfortable" : "compact",
})
}
>
密度: {canvas?.prefs.density ?? "comfortable"}
</button>
<button
type="button"
onClick={() =>
void setPrefs({ branchColoring: !(canvas?.prefs.branchColoring ?? true) })
}
>
分色: {canvas?.prefs.branchColoring === false ? "关" : "开"}
</button>
<button
type="button"
onClick={() =>
void setPrefs({
nodeViewMode:
canvas?.prefs.nodeViewMode !== "bubble" ? "bubble" : "card",
})
}
>
模式: {canvas?.prefs.nodeViewMode !== "bubble" ? "卡片" : "气泡"}
</button>
<button
type="button"
onClick={() => {
const id = canvasId;
if (!id) return;
void fetch(`/api/canvases/${id}/export.json`)
.then((r) => r.json())
.then((data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.json`;
a.click();
});
}}
>
导出JSON
</button>
<button
type="button"
onClick={() => {
const el = document.querySelector(".canvas-host canvas") as HTMLCanvasElement | null;
if (!el) return;
el.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.png`;
a.click();
});
}}
>
导出PNG
</button>
<div className="spacer" />
</header>
<div
className={`main ${leftCollapsed ? "left-collapsed" : ""}`}
>
<aside className="panel" style={{ display: leftCollapsed ? "none" : undefined }}>
<div className="panel-header">目录</div>
<div className="panel-body">
<div className="muted" style={{ marginBottom: 8 }}>
画布
</div>
{canvases.map((c) => (
<div
key={c.id}
className={`canvas-list-item ${c.id === canvasId ? "active" : ""}`}
onClick={() => void openCanvas(c.id)}
>
{c.title}
</div>
))}
<div className="muted" style={{ margin: "12px 0 8px" }}>
大纲
</div>
{outline.map((n) => (
<div
key={n.id}
className={`outline-item ${selectedIds.includes(n.id) ? "active" : ""}`}
onClick={() => {
useAppStore.getState().setSelection([n.id]);
void applyOps([{ type: "set_focus", nodeId: n.id }]);
}}
>
{n.id === focusId ? "◎ " : "• "}
{n.text}
</div>
))}
<div className="muted" style={{ margin: "12px 0 8px" }}>
属性
</div>
<NodeInspector />
<div className="muted" style={{ margin: "12px 0 8px" }}>
回收站 ({trash.length})
</div>
{trash.length === 0 && <div className="muted">空</div>}
{trash.map((n) => (
<div key={n.id} className="trash-item">
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{n.text}</span>
<button type="button" onClick={() => void restoreFromTrash(n.id)}>
恢复
</button>
</div>
))}
</div>
</aside>
<TopologyCanvas />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import type { MindNode } from "@mind-map/shared";
export function HoverCard({
node,
x,
y,
}: {
node: MindNode;
x: number;
y: number;
}) {
const tags = (node.tags ?? []).slice(0, 4);
const note = (node.note ?? "").trim();
const links = node.links?.length ?? 0;
return (
<div className="hover-card" style={{ left: x, top: y }}>
<div className="hover-card__title">{node.text}</div>
{note ? <div className="hover-card__note">{note.slice(0, 120)}</div> : null}
{tags.length ? (
<div className="hover-card__tags">
{tags.map((t) => (
<span key={t} className="hover-card__tag">
{t}
</span>
))}
</div>
) : null}
<div className="hover-card__meta">
{links ? `${links} 链接` : null}
{node.dueAt != null
? `${links ? " · " : ""}截止 ${new Date(node.dueAt).toLocaleDateString()}`
: null}
{node.stylePreset !== "default" ? `${links || node.dueAt ? " · " : ""}${node.stylePreset}` : null}
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import type { MindEdge, MindNode } from "@mind-map/shared";
import { useEffect, useMemo, useState } from "react";
import { NodePropEditor } from "./node-props";
import { useAppStore } from "./store";
type ApplyOps = ReturnType<typeof useAppStore.getState>["applyOps"];
export function NodeInspector() {
const canvas = useAppStore((s) => s.canvas);
const nodes = useAppStore((s) => s.nodes);
const edges = useAppStore((s) => s.edges);
const selectedIds = useAppStore((s) => s.selectedIds);
const selectedEdgeId = useAppStore((s) => s.selectedEdgeId);
const applyOps = useAppStore((s) => s.applyOps);
const mode = canvas?.prefs.inspectorMode ?? "selection";
const node = useMemo(
() => (selectedIds[0] ? nodes.find((n) => n.id === selectedIds[0]) : undefined),
[nodes, selectedIds],
);
const edge = useMemo(
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : undefined),
[edges, selectedEdgeId],
);
if (mode === "selection" && !node && !edge) {
return <div className="inspector muted">选中节点或边以编辑属性</div>;
}
if (edge && !node) {
return <EdgeForm edge={edge} applyOps={applyOps} />;
}
if (!node) {
return <div className="inspector muted">选中节点或边以编辑属性</div>;
}
return <NodeForm node={node} applyOps={applyOps} />;
}
function NodeForm({ node, applyOps }: { node: MindNode; applyOps: ApplyOps }) {
return (
<div className="inspector">
<div className="inspector__title">节点属性</div>
<NodePropEditor
node={node}
applyOps={applyOps}
variant="panel"
showStylePreset
/>
<div className="inspector__row" style={{ marginTop: 8 }}>
<button
type="button"
onClick={() =>
void applyOps([
{
type: "set_collapsed",
nodeId: node.id,
collapsed: !node.collapsed,
},
])
}
>
{node.collapsed ? "展开" : "折叠"}
</button>
</div>
</div>
);
}
function EdgeForm({ edge, applyOps }: { edge: MindEdge; applyOps: ApplyOps }) {
const [tagsText, setTagsText] = useState(edge.tags?.join(", ") ?? "");
useEffect(() => {
setTagsText(edge.tags?.join(", ") ?? "");
}, [edge.id, edge.version, edge.tags]);
return (
<div className="inspector">
<div className="inspector__title">边属性</div>
<label className="inspector__field">
<span>标签</span>
<input
key={`el-${edge.id}-${edge.version}`}
defaultValue={edge.label ?? ""}
onBlur={(e) => {
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { label: e.target.value || null },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>备注</span>
<textarea
key={`en-${edge.id}-${edge.version}`}
defaultValue={edge.note ?? ""}
rows={2}
onBlur={(e) => {
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { note: e.target.value || null },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>线粗 (1-5)</span>
<input
key={`ew-${edge.id}-${edge.version}`}
type="number"
min={1}
max={5}
defaultValue={edge.weight ?? 1}
onBlur={(e) => {
const n = Number(e.target.value);
const weight = Number.isFinite(n)
? Math.max(1, Math.min(5, Math.round(n)))
: null;
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { weight },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>线型</span>
<select
key={`es-${edge.id}-${edge.version}`}
defaultValue={edge.lineStyle ?? (edge.kind === "relation" ? "dashed" : "solid")}
onChange={(e) => {
const v = e.target.value as "solid" | "dashed" | "dotted";
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { lineStyle: v },
},
]);
}}
>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
<option value="dotted">点线</option>
</select>
</label>
<label className="inspector__field">
<span>箭头</span>
<select
key={`ed-${edge.id}-${edge.version}`}
defaultValue={
edge.direction ?? (edge.kind === "relation" ? "forward" : "none")
}
onChange={(e) => {
const v = e.target.value as "forward" | "both" | "none";
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { direction: v },
},
]);
}}
>
<option value="forward">单向 →</option>
<option value="both">双向 ↔</option>
<option value="none">无箭头</option>
</select>
</label>
<label className="inspector__field">
<span>标签(逗号分隔)</span>
<input
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
onBlur={() => {
const tags = tagsText
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean);
void applyOps([{ type: "update_edge_meta", edgeId: edge.id, patch: { tags } }]);
}}
/>
</label>
<div className="muted" style={{ fontSize: 11 }}>
{edge.kind} · {edge.from.slice(0, 8)} → {edge.to.slice(0, 8)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
/** Shared canvas typography + chrome helpers for bubble/card nodes. */
export const CANVAS_FONT =
'"Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif';
export function canvasFont(
sizePx: number,
weight: 400 | 500 | 600 | 700 = 400,
): string {
return `${weight === 400 ? "" : `${weight} `}${sizePx}px ${CANVAS_FONT}`;
}
/** Soft drop shadow under a node (world units). */
export function paintNodeShadow(
ctx: CanvasRenderingContext2D,
zoom: number,
opts?: { selected?: boolean },
) {
ctx.shadowColor = opts?.selected
? "rgba(0, 0, 0, 0.45)"
: "rgba(0, 0, 0, 0.28)";
ctx.shadowBlur = (opts?.selected ? 18 : 12) / Math.max(0.5, zoom);
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 3 / Math.max(0.5, zoom);
}
export function clearShadow(ctx: CanvasRenderingContext2D) {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
/** Low-alpha accent wash clipped to current path. */
export function fillAccentWash(
ctx: CanvasRenderingContext2D,
accent: string,
alpha = 0.14,
) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = accent;
ctx.fill();
ctx.restore();
}
/** Left accent rail inside a rounded rect (not for diamonds/pills). */
export function paintAccentRail(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
h: number,
accent: string,
radius = 10,
) {
const railW = 4;
ctx.save();
ctx.beginPath();
const rr = Math.min(radius, h / 2);
ctx.moveTo(x + rr, y);
ctx.lineTo(x + railW, y);
ctx.lineTo(x + railW, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
ctx.fillStyle = accent;
ctx.globalAlpha = 0.95;
ctx.fill();
ctx.restore();
}
export function paintBadgeChip(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
theme: { labelSecondary: string; border: string; bgLayer1: string },
zoom: number,
): number {
const padX = 6;
const h = 16 / Math.max(0.75, Math.min(1.2, zoom));
ctx.font = canvasFont(10 / Math.max(0.75, Math.min(1.2, zoom)), 500);
const tw = ctx.measureText(text).width;
const w = tw + padX * 2;
ctx.beginPath();
const r = h / 2;
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.06)";
ctx.fill();
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
ctx.stroke();
ctx.fillStyle = theme.labelSecondary;
ctx.textBaseline = "middle";
ctx.fillText(text, x + padX, y + h / 2);
return w + 4;
}

View file

@ -0,0 +1,56 @@
import type { MindEdge } from "@mind-map/shared";
/** Primary-hierarchy descendants including root. */
export function collectSubtreeIds(
rootId: string,
edges: MindEdge[],
): string[] {
const kids = new Map<string, string[]>();
for (const e of edges) {
if (e.deletedAt || e.kind !== "hierarchy" || !e.isPrimaryParent) continue;
const list = kids.get(e.from) ?? [];
list.push(e.to);
kids.set(e.from, list);
}
const out: string[] = [rootId];
const seen = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length) {
const cur = queue.shift()!;
for (const c of kids.get(cur) ?? []) {
if (seen.has(c)) continue;
seen.add(c);
out.push(c);
queue.push(c);
}
}
return out;
}
export function isDescendantOrSelf(
candidateId: string,
rootId: string,
edges: MindEdge[],
): boolean {
return collectSubtreeIds(rootId, edges).includes(candidateId);
}
export type DragGhost = {
rootId: string;
/** World delta from positions at pointer-down. */
dx: number;
dy: number;
ids: string[];
};
export function ghostPos(
id: string,
base: { x: number; y: number } | undefined,
ghost: DragGhost | null,
): { x: number; y: number } | undefined {
if (!base) return undefined;
if (ghost && ghost.ids.includes(id)) {
return { x: base.x + ghost.dx, y: base.y + ghost.dy };
}
return base;
}

View file

@ -0,0 +1,31 @@
/** Shared image loader for canvas card thumbnails. */
const cache = new Map<string, HTMLImageElement | "loading" | "error">();
export function getCachedImage(
url: string,
onUpdate: () => void,
): HTMLImageElement | null {
const hit = cache.get(url);
if (hit instanceof HTMLImageElement) return hit;
if (hit === "loading" || hit === "error") return null;
cache.set(url, "loading");
const img = new Image();
img.decoding = "async";
// Best-effort; some CDNs block canvas without CORS — we still draw when allowed.
img.crossOrigin = "anonymous";
img.onload = () => {
cache.set(url, img);
onUpdate();
};
img.onerror = () => {
cache.set(url, "error");
onUpdate();
};
img.src = url;
return null;
}
export function imageLoadFailed(url: string): boolean {
return cache.get(url) === "error";
}

5
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);

View file

@ -0,0 +1,180 @@
import type { MindNode, StylePreset } from "@mind-map/shared";
import { estimateCardSize } from "./node-props/sizing";
/** Keep roughly in sync with layout-engine nodeLayoutSize (comfortable / compact). */
export function nodeBoxSize(
n: MindNode,
dens: boolean,
opts?: { asCard?: boolean },
): { w: number; h: number } {
if (opts?.asCard || n.stylePreset === "card") {
return estimateCardSize(n, dens);
}
let w = dens ? 168 : 212;
let h = dens ? 48 : 60;
const hasMeta =
Boolean(n.tags?.length) ||
Boolean(n.links?.length) ||
n.dueAt != null ||
Boolean(n.note);
h += hasMeta ? (dens ? 20 : 26) : dens ? 8 : 10;
const p: StylePreset = n.stylePreset;
if (p === "title") {
w *= 1.18;
h *= 1.15;
} else if (p === "decision") {
w *= 1.08;
h *= 1.12;
} else if (p === "note") {
h *= 1.12;
}
if (n.imageUrl) {
w = Math.max(w, dens ? 200 : 248);
h += dens ? 78 : 96;
}
return { w, h };
}
export function drawNodeShape(
ctx: CanvasRenderingContext2D,
preset: StylePreset,
x: number,
y: number,
w: number,
h: number,
radius: number,
) {
ctx.beginPath();
if (preset === "decision") {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.moveTo(cx, y);
ctx.lineTo(x + w, cy);
ctx.lineTo(cx, y + h);
ctx.lineTo(x, cy);
ctx.closePath();
} else if (preset === "note") {
const fold = Math.min(12, w * 0.12);
ctx.moveTo(x, y);
ctx.lineTo(x + w - fold, y);
ctx.lineTo(x + w, y + fold);
ctx.lineTo(x + w, y + h);
ctx.lineTo(x, y + h);
ctx.closePath();
} else if (preset === "title") {
const r = h / 2;
roundRectPath(ctx, x, y, w, h, r);
} else {
// default / risk / muted / card
roundRectPath(ctx, x, y, w, h, radius);
}
}
export function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2);
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
/** Wrap text to at most `maxLines` lines within `maxWidth`. */
export function wrapTextLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
maxLines: number,
): string[] {
const raw = text.replace(/\s+/g, " ").trim();
if (!raw) return [];
const lines: string[] = [];
let rest = raw;
while (rest && lines.length < maxLines) {
if (ctx.measureText(rest).width <= maxWidth) {
lines.push(rest);
break;
}
let lo = 1;
let hi = rest.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(rest.slice(0, mid)).width <= maxWidth) lo = mid;
else hi = mid - 1;
}
let cut = Math.max(1, lo);
const space = rest.lastIndexOf(" ", cut);
if (space > cut * 0.4) cut = space;
let line = rest.slice(0, cut).trimEnd();
rest = rest.slice(cut).trimStart();
if (lines.length === maxLines - 1 && rest) {
while (line.length > 1 && ctx.measureText(`${line}…`).width > maxWidth) {
line = line.slice(0, -1);
}
lines.push(`${line}…`);
break;
}
lines.push(line);
}
return lines;
}
export function cardSourceLabel(n: MindNode): string | null {
const link = n.links?.[0];
if (!link) return null;
if (link.title?.trim()) return link.title.trim().slice(0, 28);
try {
return new URL(link.url).hostname.replace(/^www\./, "");
} catch {
return link.url.slice(0, 28);
}
}
export function cardTimeLabel(n: MindNode): string | null {
const ms = n.startAt ?? n.dueAt ?? null;
if (ms == null || !Number.isFinite(ms)) return null;
try {
return new Date(ms).toLocaleDateString();
} catch {
return null;
}
}
export function nodeBadges(n: MindNode): string[] {
const out: string[] = [];
if (n.icon) {
const glyph = ICON_GLYPH[n.icon];
if (glyph) out.push(glyph);
}
if (n.collapsed) out.push("▸");
if (n.links?.length) out.push("🔗");
if (n.imageUrl) out.push("🖼");
if (n.dueAt != null) out.push("⏰");
if (n.tags?.length) out.push(`#${n.tags.length}`);
if (n.pinned) out.push("📌");
return out;
}
const ICON_GLYPH: Record<string, string> = {
server: "[服]",
database: "[库]",
cloud: "[云]",
person: "[人]",
folder: "[夹]",
doc: "[档]",
link: "[链]",
warning: "[警]",
check: "[✓]",
star: "[★]",
gear: "[设]",
globe: "[球]",
};

View file

@ -0,0 +1,411 @@
import type { MindNode, Op, StylePreset } from "@mind-map/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import {
commitField,
fieldEditorSeed,
hiddenAddableFields,
visiblePropRows,
} from "./registry";
import type { PropFieldDef, PropFieldId } from "./types";
import { uploadImageFile } from "./upload-image";
import "./node-shell.css";
type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
export function NodePropEditor({
node,
applyOps,
accent,
variant = "overlay",
onDragHandlePointerDown,
showStylePreset,
}: {
node: MindNode;
applyOps: ApplyOps;
accent?: string;
variant?: "overlay" | "panel";
onDragHandlePointerDown?: (e: React.PointerEvent) => void;
showStylePreset?: boolean;
}) {
const [forced, setForced] = useState<Set<string>>(() => new Set());
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
setForced(new Set());
setMenuOpen(false);
}, [node.id]);
const rows = useMemo(
() => visiblePropRows(node, forced),
[node, forced],
);
const addable = useMemo(
() => hiddenAddableFields(node, forced),
[node, forced],
);
const commit = (id: PropFieldId, raw: string) => {
const ops = commitField(node, id, raw);
if (!ops.length) return;
void applyOps(ops, `prop ${id}`);
if (!raw.trim() && id !== "text") {
setForced((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
return (
<div
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
>
<div
className="ns-editor__header"
data-ns-drag-handle
onPointerDown={onDragHandlePointerDown}
>
<span
className="ns-editor__dot"
style={accent ? { background: accent } : undefined}
/>
<input
className="ns-editor__title"
key={`title-${node.id}-${node.version}`}
defaultValue={node.text}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.target.value !== node.text) commit("text", e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
</div>
<div className="ns-editor__body">
{showStylePreset ? (
<div className="ns-row">
<span className="ns-row__label">类型</span>
<div className="ns-row__ctrl">
<select
value={node.stylePreset}
onChange={(e) => {
void applyOps(
[
{
type: "set_style_preset",
nodeId: node.id,
stylePreset: e.target.value as StylePreset,
},
],
"style",
);
}}
>
<option value="default">默认外形</option>
<option value="title">title</option>
<option value="decision">决策</option>
<option value="risk">风险</option>
<option value="note">便签</option>
<option value="muted">弱化</option>
</select>
</div>
<span />
</div>
) : null}
<div className="ns-row">
<span className="ns-row__label">强调色</span>
<div className="ns-row__ctrl">
<input
type="text"
placeholder="#e74c3c"
defaultValue={node.accentColor ?? ""}
key={`ac-${node.id}-${node.version}`}
onBlur={(e) => {
const v = e.target.value.trim();
const next = v || null;
if ((node.accentColor ?? null) === next) return;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: { accentColor: next as never },
},
]);
}}
/>
</div>
<span />
</div>
<div className="ns-row">
<span className="ns-row__label">图标</span>
<div className="ns-row__ctrl">
<select
value={node.icon ?? ""}
onChange={(e) => {
const v = e.target.value;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: {
icon: (v || null) as never,
},
},
]);
}}
>
<option value="">无</option>
<option value="server">🖥 服务器</option>
<option value="database">🗄 数据库</option>
<option value="cloud">☁ 云</option>
<option value="person">👤 人物</option>
<option value="folder">📁 文件夹</option>
<option value="doc">📄 文档</option>
<option value="link">🔗 链接</option>
<option value="warning">⚠ 警告</option>
<option value="check">✓ 完成</option>
<option value="star">★ 星标</option>
<option value="gear">⚙ 设置</option>
<option value="globe">🌐 全球</option>
</select>
</div>
<span />
</div>
{rows
.filter((r) => r.def.id !== "text")
.map(({ def }) => (
<FieldRow
key={def.id}
def={def}
node={node}
onCommit={(raw) => commit(def.id, raw)}
onClear={() => {
commit(def.id, "");
setForced((prev) => {
const next = new Set(prev);
next.delete(def.id);
return next;
});
}}
/>
))}
</div>
{addable.length > 0 ? (
<div
className="ns-add"
onPointerDown={(e) => e.stopPropagation()}
>
{menuOpen ? (
<div className="ns-add__menu">
{addable.map((f) => (
<button
key={f.id}
type="button"
className="ns-add__item"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setForced((prev) => new Set(prev).add(f.id));
setMenuOpen(false);
}}
>
{f.label}
</button>
))}
</div>
) : null}
<button
type="button"
className="ns-add__btn"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen((v) => !v);
}}
>
+ 添加字段
</button>
</div>
) : null}
</div>
);
}
function FieldRow({
def,
node,
onCommit,
onClear,
}: {
def: PropFieldDef;
node: MindNode;
onCommit: (raw: string) => void;
onClear: () => void;
}) {
const seed = fieldEditorSeed(node, def.id);
const canClear = !def.always;
const [uploading, setUploading] = useState(false);
const [uploadErr, setUploadErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
if (def.kind === "image") {
return (
<div className="ns-row">
<span className="ns-row__label">{def.label}</span>
<div className="ns-row__ctrl ns-row__ctrl--image" onPointerDown={(e) => e.stopPropagation()}>
{seed ? <img className="ns-thumb" src={seed} alt="" /> : null}
<div className="ns-image-actions">
<button
type="button"
className="ns-image-pick"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "上传中…" : "选择文件"}
</button>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploadErr(null);
setUploading(true);
void uploadImageFile(file)
.then((url) => onCommit(url))
.catch((err) =>
setUploadErr(String(err?.message || err || "上传失败")),
)
.finally(() => setUploading(false));
}}
/>
<input
key={`${def.id}-${node.id}-${node.version}`}
className="ns-image-url"
defaultValue={seed}
placeholder="或粘贴图片 URL"
onBlur={(e) => {
const v = e.target.value.trim();
if (v !== (seed || "")) onCommit(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
{canClear ? (
<button
type="button"
className="ns-row__clear"
title="清除"
onClick={onClear}
>
×
</button>
) : null}
</div>
{uploadErr ? <div className="ns-image-err">{uploadErr}</div> : null}
<div className="ns-image-hint">支持本地选图或粘贴 https://… 链接</div>
</div>
</div>
);
}
return (
<div className="ns-row">
<span className="ns-row__label">{def.label}</span>
<div className="ns-row__ctrl" onPointerDown={(e) => e.stopPropagation()}>
{def.kind === "textarea" || def.kind === "links" ? (
<textarea
key={`${def.id}-${node.id}-${node.version}`}
defaultValue={seed}
rows={def.kind === "links" ? 3 : 2}
placeholder={def.kind === "links" ? "标题|url 每行一条" : ""}
onBlur={(e) => onCommit(e.target.value)}
/>
) : def.kind === "datetime" ? (
<input
key={`${def.id}-${node.id}-${node.version}`}
type="datetime-local"
defaultValue={seed}
onBlur={(e) => onCommit(e.target.value)}
/>
) : def.kind === "flag" ? (
<button
type="button"
onClick={() => onCommit(node.pinned ? "0" : "1")}
>
{node.pinned ? "解钉" : "钉住"}
</button>
) : (
<input
key={`${def.id}-${node.id}-${node.version}`}
defaultValue={seed}
placeholder={def.kind === "tags" ? "逗号分隔" : ""}
onBlur={(e) => onCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
)}
{canClear ? (
<button
type="button"
className="ns-row__clear"
title="清除"
onClick={onClear}
>
×
</button>
) : null}
</div>
</div>
);
}
export function NodePropOverlay({
node,
applyOps,
screenX,
screenY,
zoom,
accent,
width,
showStylePreset,
}: {
node: MindNode;
applyOps: ApplyOps;
screenX: number;
screenY: number;
zoom: number;
accent?: string;
width: number;
showStylePreset?: boolean;
}) {
return (
<div
className="ns-overlay"
style={{
left: screenX,
top: screenY,
width,
transform: `translate(-50%, -50%) scale(${zoom})`,
}}
>
<NodePropEditor
node={node}
applyOps={applyOps}
accent={accent}
showStylePreset={showStylePreset}
/>
</div>
);
}

View file

@ -0,0 +1,162 @@
import type { MindNode } from "@mind-map/shared";
import { canvasFont } from "../canvas-chrome";
import { getCachedImage, imageLoadFailed } from "../image-cache";
import type { SystemTheme } from "../system-theme";
import { roundRectPath, wrapTextLines } from "../node-geometry";
import { visiblePropRows } from "./registry";
import {
CARD_GAP,
CARD_HEADER_H,
CARD_PAD_X,
CARD_PAD_Y,
estimateCardSize,
} from "./sizing";
/** Read-only card chrome + stacked property rows (shell already filled by canvas). */
export function drawPropSheet(
ctx: CanvasRenderingContext2D,
node: MindNode,
x: number,
y: number,
theme: SystemTheme,
zoom: number,
dens: boolean,
accent: string,
onImage: () => void,
) {
const { w, h } = estimateCardSize(node, dens);
const rows = visiblePropRows(node);
const radius = dens ? 12 : 14;
// Top accent hairline
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x, y, w, h, radius);
ctx.clip();
ctx.fillStyle = accent;
ctx.globalAlpha = 0.9;
ctx.fillRect(x, y, w, 3);
ctx.globalAlpha = 1;
// Low zoom: title only
if (zoom < 0.35) {
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 12 : 13, 600);
ctx.textBaseline = "middle";
const lines = wrapTextLines(
ctx,
node.text || "未命名",
w - CARD_PAD_X * 2,
2,
);
const lineH = dens ? 14 : 16;
const startY = y + h / 2 - ((lines.length - 1) * lineH) / 2;
lines.forEach((line, i) => {
ctx.fillText(line, x + CARD_PAD_X, startY + i * lineH);
});
ctx.restore();
return;
}
// Header band
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.fillRect(x, y, w, CARD_HEADER_H);
ctx.beginPath();
ctx.arc(x + 14, y + CARD_HEADER_H / 2, 4.5, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.fill();
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 12 : 13, 600);
ctx.textBaseline = "middle";
const titleMaxW = w - 28 - CARD_PAD_X;
const titleLines = wrapTextLines(
ctx,
node.text || "未命名",
titleMaxW,
dens ? 1 : 2,
);
if (titleLines.length === 1) {
ctx.fillText(titleLines[0]!, x + 26, y + CARD_HEADER_H / 2);
} else {
const lh = 13;
const ty = y + CARD_HEADER_H / 2 - lh / 2;
titleLines.forEach((line, i) => {
ctx.fillText(line, x + 26, ty + i * lh);
});
}
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
ctx.beginPath();
ctx.moveTo(x + CARD_PAD_X, y + CARD_HEADER_H);
ctx.lineTo(x + w - CARD_PAD_X, y + CARD_HEADER_H);
ctx.stroke();
let cy = y + CARD_HEADER_H + CARD_PAD_Y;
const contentW = w - CARD_PAD_X * 2;
for (const { def, preview } of rows) {
if (def.id === "text") continue;
if (def.imageRow && preview) {
const ih = dens ? 52 : 64;
const img = getCachedImage(preview, onImage);
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 8);
ctx.clip();
if (img && img.naturalWidth > 0) {
// Contain: 等比例缩小完整入框,不裁切
const scale = Math.min(
contentW / img.naturalWidth,
ih / img.naturalHeight,
);
const dw = img.naturalWidth * scale;
const dh = img.naturalHeight * scale;
ctx.fillStyle = "rgba(0,0,0,0.22)";
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.drawImage(
img,
x + CARD_PAD_X + (contentW - dw) / 2,
cy + (ih - dh) / 2,
dw,
dh,
);
} else {
ctx.fillStyle = theme.border;
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(11, 500);
ctx.textBaseline = "middle";
ctx.fillText(
imageLoadFailed(preview) ? "配图失败" : "加载中…",
x + CARD_PAD_X + 10,
cy + ih / 2,
);
}
ctx.restore();
cy += ih + CARD_GAP;
continue;
}
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(dens ? 10 : 11, 500);
ctx.textBaseline = "top";
ctx.fillText(def.label, x + CARD_PAD_X, cy);
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 11 : 12, 400);
const text = preview ?? "—";
const maxLines = def.kind === "textarea" ? 3 : 2;
const lines = wrapTextLines(ctx, text, contentW, maxLines);
let ly = cy + (dens ? 14 : 15);
for (const line of lines) {
ctx.fillText(line, x + CARD_PAD_X, ly);
ly += dens ? 14 : 15;
}
cy = Math.max(cy + def.rowHeight, ly) + CARD_GAP;
}
ctx.restore();
}

View file

@ -0,0 +1,5 @@
export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry";
export { estimateCardSize, CARD_WIDTH } from "./sizing";
export { drawPropSheet } from "./draw-sheet";
export { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
export type { PropFieldId, PropFieldDef } from "./types";

View file

@ -0,0 +1,311 @@
/* Node property system — card / inspector shell */
.ns-editor {
--ns-bg: var(--dsw-alias-bg-layer-1, #1b1e28);
--ns-border: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
--ns-label: var(--dsw-alias-label-secondary, #a5adb8);
--ns-text: var(--dsw-alias-label-primary, #f4f5f7);
--ns-brand: var(--dsw-alias-brand-primary, #5e6ad2);
--ns-input: rgba(255, 255, 255, 0.04);
--ns-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0;
background: var(--ns-bg);
border: 1px solid var(--ns-border);
border-radius: 14px;
overflow: visible;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 10px 32px rgba(0, 0, 0, 0.38);
color: var(--ns-text);
font-family: var(--ns-font);
font-size: 12.5px;
line-height: 1.45;
min-width: 248px;
max-width: 320px;
}
.ns-editor--panel {
box-shadow: none;
max-width: none;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
}
.ns-editor__header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.28) 0%,
rgba(0, 0, 0, 0.14) 100%
);
border-bottom: 1px solid var(--ns-border);
border-radius: 14px 14px 0 0;
cursor: grab;
user-select: none;
overflow: hidden;
}
.ns-editor__header:active {
cursor: grabbing;
}
.ns-editor__dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
background: var(--ns-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ns-brand) 28%, transparent);
}
.ns-editor__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: var(--ns-text);
font-family: inherit;
font-weight: 600;
font-size: 13.5px;
letter-spacing: 0.01em;
padding: 2px 0;
outline: none;
}
.ns-editor__title:focus {
box-shadow: inset 0 -1.5px 0 var(--ns-brand);
}
.ns-editor__body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px 10px;
}
.ns-row {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
align-items: start;
padding: 8px 6px;
border-radius: 8px;
}
.ns-row:hover {
background: rgba(255, 255, 255, 0.035);
}
.ns-row__label {
color: var(--ns-label);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1.2;
user-select: none;
}
.ns-row__ctrl {
min-width: 0;
display: flex;
gap: 6px;
align-items: flex-start;
}
.ns-row__ctrl input,
.ns-row__ctrl textarea,
.ns-row__ctrl select {
flex: 1;
width: 100%;
border: 1px solid transparent;
background: var(--ns-input);
color: var(--ns-text);
font-family: inherit;
font-size: 12.5px;
border-radius: 8px;
padding: 7px 10px;
outline: none;
resize: vertical;
transition: border-color 0.12s ease, background 0.12s ease;
color-scheme: dark;
}
.ns-row__ctrl select option {
color: #f4f5f7;
background: #1b1e28;
}
.ns-row__ctrl input:focus,
.ns-row__ctrl textarea:focus,
.ns-row__ctrl select:focus {
border-color: color-mix(in srgb, var(--ns-brand) 70%, transparent);
background: rgba(255, 255, 255, 0.055);
}
.ns-row__clear {
border: none;
background: transparent;
color: var(--ns-label);
padding: 6px 6px;
font-size: 11px;
line-height: 1;
opacity: 0.65;
border-radius: 6px;
flex-shrink: 0;
}
.ns-row__clear:hover {
opacity: 1;
color: var(--ns-text);
background: rgba(255, 255, 255, 0.06);
}
.ns-thumb {
width: 100%;
max-height: 140px;
height: auto;
object-fit: contain;
object-position: center;
border-radius: 8px;
background: rgba(0, 0, 0, 0.22);
display: block;
margin-bottom: 6px;
}
.ns-row__ctrl--image {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.ns-image-actions {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
}
.ns-image-pick {
flex-shrink: 0;
border: 1px solid var(--ns-border);
background: rgba(255, 255, 255, 0.06);
color: var(--ns-text);
border-radius: 8px;
padding: 7px 10px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.ns-image-pick:hover:not(:disabled) {
border-color: var(--ns-brand);
}
.ns-image-pick:disabled {
opacity: 0.6;
cursor: wait;
}
.ns-image-url {
flex: 1;
min-width: 0;
}
.ns-image-hint {
font-size: 10.5px;
color: var(--ns-label);
line-height: 1.3;
}
.ns-image-err {
font-size: 11px;
color: #f87171;
}
.ns-add {
margin: 2px 10px 10px;
position: relative;
z-index: 6;
}
.ns-add__btn {
width: 100%;
border: 1px dashed color-mix(in srgb, var(--ns-border) 80%, transparent);
background: transparent;
color: var(--ns-label);
border-radius: 10px;
padding: 8px 10px;
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.ns-add__btn:hover {
border-color: var(--ns-brand);
color: var(--ns-text);
background: rgba(255, 255, 255, 0.03);
}
.ns-add__menu {
position: absolute;
left: 0;
right: 0;
/* Open downward so it isn't clipped by siblings / host */
top: calc(100% + 6px);
bottom: auto;
background: var(--ns-bg);
border: 1px solid var(--ns-border);
border-radius: 10px;
padding: 4px;
z-index: 20;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
max-height: 220px;
overflow: auto;
pointer-events: auto;
}
.ns-add__item {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--ns-text);
font-family: inherit;
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
}
.ns-add__item:hover {
background: rgba(255, 255, 255, 0.07);
}
.ns-overlay {
position: absolute;
z-index: 8;
pointer-events: none;
transform-origin: center center;
}
.ns-editor {
pointer-events: none;
}
.ns-editor--panel {
pointer-events: auto;
}
.ns-editor__title,
.ns-editor__body,
.ns-add {
pointer-events: auto;
}

View file

@ -0,0 +1,283 @@
import type { LinkRef, MindNode, Op } from "@mind-map/shared";
import type { PropFieldDef, PropFieldId, VisiblePropRow } from "./types";
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url.slice(0, 32);
}
}
function formatDate(ms?: number): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
try {
return new Date(ms).toLocaleDateString();
} catch {
return null;
}
}
function parseDatetimeLocal(v: string): number | null {
if (!v.trim()) return null;
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
}
function parseLinks(text: string): LinkRef[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const i = line.indexOf("|");
if (i >= 0) {
return {
title: line.slice(0, i).trim(),
url: line.slice(i + 1).trim(),
};
}
return { url: line };
})
.filter((l) => l.url);
}
function linksToText(links: LinkRef[] | undefined): string {
return (links ?? [])
.map((l) => (l.title ? `${l.title}|${l.url}` : l.url))
.join("\n");
}
export const PROP_FIELDS: PropFieldDef[] = [
{
id: "text",
label: "标题",
kind: "title",
always: true,
rowHeight: 22,
isEmpty: (n) => !n.text?.trim(),
preview: (n) => n.text?.trim() || "未命名",
commit: (nodeId, raw) => [
{ type: "update_text", nodeId, text: raw.trim() || "未命名" },
],
},
{
id: "note",
label: "摘要",
kind: "textarea",
rowHeight: 18,
isEmpty: (n) => !n.note?.trim(),
preview: (n) => {
const t = n.note?.trim();
if (!t) return null;
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { note: raw.trim() || null },
},
],
},
{
id: "description",
label: "结构化描述 (JSON)",
kind: "textarea",
rowHeight: 36,
isEmpty: (n) => !n.description || Object.keys(n.description).length === 0,
preview: (n) => {
if (!n.description || !Object.keys(n.description).length) return null;
const t = JSON.stringify(n.description);
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return [
{
type: "update_node_meta",
nodeId,
patch: { description: null },
},
];
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("description must be a JSON object");
}
return [
{
type: "update_node_meta",
nodeId,
patch: { description: parsed as Record<string, unknown> },
},
];
} catch {
return [];
}
},
},
{
id: "imageUrl",
label: "配图",
kind: "image",
imageRow: true,
rowHeight: 64,
isEmpty: (n) => !n.imageUrl?.trim(),
preview: (n) => n.imageUrl?.trim() || null,
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { imageUrl: raw.trim() || null },
},
],
},
{
id: "tags",
label: "标签",
kind: "tags",
rowHeight: 18,
isEmpty: (n) => !(n.tags?.length > 0),
preview: (n) =>
n.tags?.length ? n.tags.slice(0, 4).map((t) => `#${t}`).join(" ") : null,
commit: (nodeId, raw) => {
const tags = raw
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean);
return [{ type: "update_node_meta", nodeId, patch: { tags } }];
},
},
{
id: "links",
label: "来源",
kind: "links",
rowHeight: 18,
isEmpty: (n) => !(n.links?.length > 0),
preview: (n) => {
const l = n.links?.[0];
if (!l) return null;
const head = l.title?.trim() || hostnameOf(l.url);
const more = (n.links?.length ?? 0) > 1 ? ` +${(n.links!.length - 1)}` : "";
return `${head}${more}`;
},
commit: (nodeId, raw) => [
{ type: "update_node_meta", nodeId, patch: { links: parseLinks(raw) } },
],
},
{
id: "startAt",
label: "开始",
kind: "datetime",
rowHeight: 18,
isEmpty: (n) => n.startAt == null,
preview: (n) => formatDate(n.startAt),
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { startAt: parseDatetimeLocal(raw) },
},
],
},
{
id: "dueAt",
label: "截止",
kind: "datetime",
rowHeight: 18,
isEmpty: (n) => n.dueAt == null,
preview: (n) => formatDate(n.dueAt),
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { dueAt: parseDatetimeLocal(raw) },
},
],
},
{
id: "pinned",
label: "固定",
kind: "flag",
rowHeight: 18,
isEmpty: (n) => !n.pinned,
preview: (n) => (n.pinned ? "已钉住" : null),
commit: (nodeId, raw) => {
const on = raw === "1" || raw === "true";
return [{ type: "set_pinned", nodeId, pinned: on }];
},
},
];
export const PROP_FIELD_BY_ID = Object.fromEntries(
PROP_FIELDS.map((f) => [f.id, f]),
) as Record<PropFieldId, PropFieldDef>;
/** Fields visible for display / edit given optional user-forced ids. */
export function visiblePropRows(
node: MindNode,
forced: ReadonlySet<string> = new Set(),
): VisiblePropRow[] {
const out: VisiblePropRow[] = [];
for (const def of PROP_FIELDS) {
if (def.always || !def.isEmpty(node) || forced.has(def.id)) {
out.push({ def, preview: def.preview(node) });
}
}
return out;
}
/** Fields that can be added (currently hidden). */
export function hiddenAddableFields(
node: MindNode,
forced: ReadonlySet<string> = new Set(),
): PropFieldDef[] {
return PROP_FIELDS.filter(
(f) => !f.always && f.isEmpty(node) && !forced.has(f.id),
);
}
export function fieldEditorSeed(node: MindNode, id: PropFieldId): string {
switch (id) {
case "text":
return node.text ?? "";
case "note":
return node.note ?? "";
case "description":
return node.description
? JSON.stringify(node.description, null, 2)
: "";
case "imageUrl":
return node.imageUrl ?? "";
case "tags":
return (node.tags ?? []).join(", ");
case "links":
return linksToText(node.links);
case "startAt":
return toDatetimeLocal(node.startAt);
case "dueAt":
return toDatetimeLocal(node.dueAt);
case "pinned":
return node.pinned ? "1" : "0";
default:
return "";
}
}
function toDatetimeLocal(ms?: number): string {
if (ms == null || !Number.isFinite(ms)) return "";
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function commitField(
node: MindNode,
id: PropFieldId,
raw: string,
): Op[] {
return PROP_FIELD_BY_ID[id].commit(node.id, raw, node);
}

View file

@ -0,0 +1,29 @@
import type { MindNode } from "@mind-map/shared";
import { visiblePropRows } from "./registry";
export const CARD_WIDTH = { comfortable: 288, compact: 248 } as const;
export const CARD_HEADER_H = 36;
export const CARD_PAD_Y = 12;
export const CARD_PAD_X = 12;
export const CARD_GAP = 10;
/** Layout / hit-test height for a card node. */
export function estimateCardSize(
node: MindNode,
dens: boolean,
forced: ReadonlySet<string> = new Set(),
): { w: number; h: number } {
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
const rows = visiblePropRows(node, forced);
let body = 0;
for (const { def } of rows) {
if (def.id === "text") continue;
// Stacked label+value needs a bit more than legacy side-by-side rows.
const rh =
def.imageRow ? (dens ? 52 : 64) + 4 : Math.max(def.rowHeight + 12, dens ? 28 : 32);
body += rh + CARD_GAP;
}
if (body > 0) body -= CARD_GAP;
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 12);
return { w, h: Math.max(h, dens ? 84 : 100) };
}

View file

@ -0,0 +1,41 @@
import type { MindNode, Op } from "@mind-map/shared";
export type PropFieldId =
| "text"
| "note"
| "description"
| "imageUrl"
| "tags"
| "links"
| "startAt"
| "dueAt"
| "pinned";
export type PropWidgetKind =
| "title"
| "textarea"
| "image"
| "tags"
| "links"
| "datetime"
| "flag";
export type PropFieldDef = {
id: PropFieldId;
label: string;
kind: PropWidgetKind;
/** Always show even when empty (title). */
always?: boolean;
/** Extra height when this row is an image preview. */
imageRow?: boolean;
rowHeight: number;
isEmpty: (node: MindNode) => boolean;
preview: (node: MindNode) => string | null;
/** Build ops to commit a raw editor string/value. Empty clears. */
commit: (nodeId: string, raw: string, node: MindNode) => Op[];
};
export type VisiblePropRow = {
def: PropFieldDef;
preview: string | null;
};

View file

@ -0,0 +1,77 @@
/** Compress a local image file and upload to mind-map server. */
const MAX_EDGE = 1280;
const JPEG_QUALITY = 0.82;
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("read failed"));
reader.readAsDataURL(file);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
}
/** Resize + JPEG encode in browser; returns data URL. */
export async function compressImageFile(file: File): Promise<{
dataUrl: string;
mimeType: string;
}> {
const rawUrl = await readFileAsDataUrl(file);
const img = await loadImage(rawUrl);
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(img, 0, 0, w, h);
// Keep PNG only for graphics with alpha when small; otherwise JPEG.
const preferPng =
file.type === "image/png" && file.size < 400_000 && scale === 1;
const mimeType = preferPng ? "image/png" : "image/jpeg";
const dataUrl = canvas.toDataURL(
mimeType,
preferPng ? undefined : JPEG_QUALITY,
);
return { dataUrl, mimeType };
}
export async function uploadImageFile(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
throw new Error("请选择图片文件");
}
if (file.size > 20 * 1024 * 1024) {
throw new Error("原图过大(请小于 20MB)");
}
const { dataUrl, mimeType } = await compressImageFile(file);
const res = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dataBase64: dataUrl,
mimeType,
filename: file.name,
}),
});
const body = (await res.json().catch(() => ({}))) as {
ok?: boolean;
url?: string;
message?: string;
};
if (!res.ok || !body.ok || !body.url) {
throw new Error(body.message || `上传失败 (${res.status})`);
}
return body.url;
}

378
apps/web/src/store.ts Normal file
View file

@ -0,0 +1,378 @@
import type { CanvasMeta, MindEdge, MindNode, Op, StylePreset } from "@mind-map/shared";
import { create } from "zustand";
export type Positions = Record<string, { x: number; y: number }>;
type State = {
canvases: CanvasMeta[];
canvasId: string | null;
canvas: CanvasMeta | null;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
selectedIds: string[];
selectedEdgeId: string | null;
/** Transient find/focus flash ids. */
highlightIds: string[];
highlightEdgeIds: string[];
highlightStyle: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
} | null;
/** One-shot camera fit from server auto_fit. */
fitViewport: { x: number; y: number; zoom: number } | null;
leftCollapsed: boolean;
themeId: string;
lastChangeSetId: string | null;
loadCanvases: () => Promise<void>;
openCanvas: (id: string) => Promise<void>;
createCanvas: (title?: string) => Promise<void>;
applyOps: (ops: Op[], summary?: string) => Promise<void>;
applyCanvasPayload: (
data: {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
},
opts?: { preserveUnpinned?: boolean },
) => void;
patchLocal: (patch: {
nodes?: MindNode[];
edges?: MindEdge[];
positions?: Positions;
canvas?: CanvasMeta;
}) => void;
requestFitViewport: (vp: { x: number; y: number; zoom: number }) => void;
consumeFitViewport: () => { x: number; y: number; zoom: number } | null;
undo: () => Promise<void>;
setSelection: (ids: string[]) => void;
setSelectedEdge: (id: string | null) => void;
flashHighlight: (
idsOrOpts:
| string[]
| {
nodeIds?: string[];
edgeIds?: string[];
style?: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
} | null;
ttlMs?: number;
},
) => void;
setThemeId: (id: string) => void;
setCollapsedPanels: (side: "left", collapsed: boolean) => void;
setPrefs: (patch: Partial<CanvasMeta["prefs"]>) => Promise<void>;
setStylePreset: (nodeId: string, stylePreset: StylePreset) => Promise<void>;
loadTrash: () => Promise<void>;
restoreFromTrash: (nodeId: string) => Promise<void>;
trash: MindNode[];
};
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const data = (await res.json()) as T & { ok?: boolean; message?: string; error?: string };
if (!res.ok) throw new Error(data.message ?? data.error ?? res.statusText);
return data;
}
function softMergePositions(prev: Positions, next: Positions): Positions {
const out: Positions = { ...prev };
for (const [id, p] of Object.entries(next)) {
out[id] = { ...p };
}
for (const id of Object.keys(out)) {
if (!(id in next)) delete out[id];
}
return out;
}
/** After pin-only ops: keep non-pinned nodes where the client already had them. */
function mergePinStable(
before: Positions,
server: Positions,
pinnedIds: Set<string>,
): Positions {
const out: Positions = { ...server };
for (const [id, p] of Object.entries(before)) {
if (!pinnedIds.has(id) && out[id]) {
out[id] = { ...p };
}
}
for (const id of pinnedIds) {
if (server[id]) out[id] = { ...server[id]! };
else if (before[id]) out[id] = { ...before[id]! };
}
return out;
}
const pinStableFreeze = new Map<
string,
{ positions: Positions; pinnedIds: string[]; until: number }
>();
export const useAppStore = create<State>((set, get) => ({
canvases: [],
canvasId: null,
canvas: null,
nodes: [],
edges: [],
positions: {},
selectedIds: [],
selectedEdgeId: null,
highlightIds: [],
highlightEdgeIds: [],
highlightStyle: null,
fitViewport: null,
leftCollapsed: false,
themeId: localStorage.getItem("mind-map:theme") ?? "abyss",
lastChangeSetId: null,
trash: [],
loadCanvases: async () => {
const data = await api<{ canvases: CanvasMeta[] }>("/api/canvases");
set({ canvases: data.canvases });
if (!get().canvasId && data.canvases[0]) {
await get().openCanvas(data.canvases[0].id);
}
},
openCanvas: async (id) => {
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
}>(`/api/canvases/${id}`);
set({
canvasId: id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions: data.positions,
selectedIds: [],
selectedEdgeId: null,
themeId: data.canvas.prefs.themeId ?? get().themeId,
});
await get().loadTrash();
},
createCanvas: async (title) => {
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
}>("/api/canvases", {
method: "POST",
body: JSON.stringify({ title }),
});
await get().loadCanvases();
set({
canvasId: data.canvas.id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions: data.positions,
selectedIds: [],
});
},
patchLocal: (patch) => {
set((s) => ({
nodes: patch.nodes ?? s.nodes,
edges: patch.edges ?? s.edges,
positions: patch.positions ?? s.positions,
canvas: patch.canvas ?? s.canvas,
}));
},
requestFitViewport: (vp) => set({ fitViewport: vp }),
consumeFitViewport: () => {
const vp = get().fitViewport;
if (vp) set({ fitViewport: null });
return vp;
},
applyOps: async (ops, summary) => {
const id = get().canvasId;
if (!id) return;
const touchesTrash = ops.some(
(o) => o.type === "delete_node" || o.type === "restore_node",
);
const touchesRoster = ops.some(
(o) => o.type === "set_prefs" || o.type === "update_text",
);
const onlyPins =
ops.length > 0 && ops.every((o) => o.type === "set_pinned");
const pinnedIds = new Set(
ops
.filter(
(o): o is Extract<Op, { type: "set_pinned" }> =>
o.type === "set_pinned" && o.pinned === true,
)
.map((o) => o.nodeId),
);
// Snapshot after any optimistic patchLocal the caller already applied.
const beforePos = { ...get().positions };
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
changeSetId: string;
fitViewport?: { x: number; y: number; zoom: number };
}>(`/api/canvases/${id}/ops`, {
method: "POST",
body: JSON.stringify({ ops, origin: "user", summary }),
});
const positions =
onlyPins && pinnedIds.size > 0
? mergePinStable(beforePos, data.positions, pinnedIds)
: softMergePositions(beforePos, data.positions);
set({
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions,
lastChangeSetId: data.changeSetId,
themeId: data.canvas.prefs.themeId,
...(data.fitViewport ? { fitViewport: data.fitViewport } : {}),
});
// Remember pin-stable freeze so a trailing WS refresh doesn't undo single-node drag.
if (onlyPins && pinnedIds.size > 0) {
pinStableFreeze.set(id, {
positions: { ...positions },
pinnedIds: [...pinnedIds],
until: Date.now() + 2000,
});
}
if (touchesRoster) void get().loadCanvases();
if (touchesTrash) void get().loadTrash();
},
applyCanvasPayload: (
data: {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
},
opts?: { preserveUnpinned?: boolean },
) => {
const id = data.canvas.id;
const freeze = pinStableFreeze.get(id);
const before = get().positions;
let positions = data.positions;
if (opts?.preserveUnpinned || (freeze && freeze.until > Date.now())) {
const pinnedIds = new Set(
freeze?.pinnedIds ??
data.nodes.filter((n) => n.pinned && n.pos).map((n) => n.id),
);
positions = mergePinStable(
freeze?.positions ?? before,
data.positions,
pinnedIds,
);
}
set({
canvasId: id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions,
themeId: data.canvas.prefs.themeId ?? get().themeId,
});
},
undo: async () => {
const id = get().canvasId;
const cs = get().lastChangeSetId;
if (!id || !cs) return;
const data = await api<{
snapshot: { canvas: CanvasMeta; nodes: MindNode[]; edges: MindEdge[] };
changeSetId: string;
}>(`/api/canvases/${id}/undo`, {
method: "POST",
body: JSON.stringify({ changeSetId: cs }),
});
await get().openCanvas(id);
set({ lastChangeSetId: data.changeSetId });
},
setSelection: (ids) => set({ selectedIds: ids, selectedEdgeId: null }),
setSelectedEdge: (id) => set({ selectedEdgeId: id, selectedIds: id ? [] : get().selectedIds }),
flashHighlight: (idsOrOpts) => {
const opts = Array.isArray(idsOrOpts)
? { nodeIds: idsOrOpts }
: idsOrOpts;
const nodeIds = opts.nodeIds ?? [];
const edgeIds = opts.edgeIds ?? [];
const style = opts.style ?? null;
const ttlMs = Math.max(500, Math.min(60000, opts.ttlMs ?? 2200));
set({
highlightIds: nodeIds,
highlightEdgeIds: edgeIds,
highlightStyle: style,
selectedIds: nodeIds.length ? nodeIds : get().selectedIds,
selectedEdgeId: edgeIds.length === 1 ? edgeIds[0]! : get().selectedEdgeId,
});
window.setTimeout(() => {
const cur = useAppStore.getState();
if (
cur.highlightIds.length === nodeIds.length &&
cur.highlightIds.every((x, i) => x === nodeIds[i]) &&
cur.highlightEdgeIds.length === edgeIds.length &&
cur.highlightEdgeIds.every((x, i) => x === edgeIds[i])
) {
set({
highlightIds: [],
highlightEdgeIds: [],
highlightStyle: null,
});
}
}, ttlMs);
},
setThemeId: (id) => {
localStorage.setItem("mind-map:theme", id);
set({ themeId: id });
},
setCollapsedPanels: (side, collapsed) => {
if (side === "left") set({ leftCollapsed: collapsed });
},
setPrefs: async (patch) => {
await get().applyOps([{ type: "set_prefs", prefs: patch }], "update prefs");
},
setStylePreset: async (nodeId, stylePreset) => {
await get().applyOps([{ type: "set_style_preset", nodeId, stylePreset }], "style");
},
loadTrash: async () => {
const id = get().canvasId;
if (!id) return;
const data = await api<{ nodes: MindNode[] }>(`/api/canvases/${id}/trash`);
set({ trash: data.nodes });
},
restoreFromTrash: async (nodeId) => {
const id = get().canvasId;
if (!id) return;
await api(`/api/canvases/${id}/trash/${nodeId}/restore`, { method: "POST" });
await get().openCanvas(id);
await get().loadTrash();
},
}));

560
apps/web/src/styles.css Normal file
View file

@ -0,0 +1,560 @@
:root {
color: var(--dsw-alias-label-primary, #f4f5f7);
background: var(--dsw-alias-bg-base, #101014);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
line-height: 1.45;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
overflow: hidden;
}
button,
input {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.08));
border-radius: 8px;
padding: 6px 10px;
}
button:hover {
background: var(--dsw-alias-interactive-bg-hover, rgba(94, 106, 210, 0.16));
}
button.primary {
background: var(--dsw-alias-brand-primary, #5e6ad2);
color: var(--dsw-alias-brand-text, #fff);
border-color: transparent;
}
input {
background: var(--dsw-specific-input-major, rgba(255, 255, 255, 0.08));
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
border-radius: 8px;
padding: 6px 8px;
outline: none;
}
.app-shell {
display: grid;
grid-template-rows: 48px 1fr;
height: 100%;
}
.app-shell.embed {
grid-template-rows: 1fr;
}
.embed-main {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 1fr;
min-height: 0;
height: 100%;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-alias-bg-layer-1, #1b1e28);
}
.topbar .brand {
font-weight: 650;
letter-spacing: 0.02em;
margin-right: 8px;
}
.topbar .spacer {
flex: 1;
}
.main {
display: grid;
grid-template-columns: var(--left, 240px) 1fr;
min-height: 0;
}
/* Aside is display:none when collapsed — only canvas remains as grid child,
so switch to a single column or it sits in the 0-width track and vanishes. */
.main.left-collapsed {
grid-template-columns: 1fr;
}
.panel {
min-width: 0;
min-height: 0;
border-right: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-specific-sidebar-fill, rgba(16, 16, 20, 0.92));
display: flex;
flex-direction: column;
}
.panel-header {
padding: 10px 12px;
font-weight: 600;
color: var(--dsw-alias-label-secondary, #a5adb8);
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
}
.panel-body {
flex: 1;
overflow: auto;
padding: 8px;
}
.canvas-host {
position: relative;
min-width: 0;
min-height: 0;
background: var(--dsw-alias-bg-base, #101014);
}
.canvas-host canvas {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.canvas-list-item,
.outline-item {
padding: 8px 10px;
border-radius: 8px;
margin-bottom: 4px;
cursor: pointer;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-list-item:hover,
.outline-item:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.05));
}
.canvas-list-item.active,
.outline-item.active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.09));
}
.muted {
color: var(--dsw-alias-label-tertiary, #7b838f);
}
.skin-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.skin-swatch {
height: 28px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
}
.skin-swatch.active {
border-color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.proposal-box {
padding: 8px 10px;
border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
max-height: 180px;
overflow: auto;
}
.proposal-item {
padding: 8px;
border-radius: 8px;
margin-bottom: 6px;
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.06));
}
.canvas-hint {
position: absolute;
left: 56px;
bottom: 10px;
font-size: 12px;
pointer-events: none;
opacity: 0.75;
max-width: calc(100% - 240px);
z-index: 1;
}
.canvas-controls {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 92%,
transparent
);
backdrop-filter: blur(8px);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
}
.canvas-controls__btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: 0;
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
border-radius: 0;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
cursor: pointer;
}
.canvas-controls__btn:last-child {
border-bottom: 0;
}
.canvas-controls__btn:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.06));
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-controls__btn.is-active {
color: var(--dsw-alias-state-warning, #d4a017);
background: rgba(212, 160, 23, 0.12);
}
.canvas-controls__btn svg {
display: block;
}
.canvas-toolbar {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 10px;
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 92%,
transparent
);
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
backdrop-filter: blur(8px);
z-index: 4;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.22);
max-width: calc(100% - 24px);
}
.canvas-toolbar__group {
display: flex;
align-items: center;
gap: 2px;
}
.canvas-toolbar__sep {
width: 1px;
height: 18px;
background: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
margin: 0 2px;
}
.canvas-toolbar__zoom {
font-size: 11px;
color: var(--dsw-alias-label-tertiary, #7b838f);
min-width: 36px;
text-align: right;
padding-left: 4px;
}
.canvas-tool-btn {
appearance: none;
border: none;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
font-size: 12px;
line-height: 1;
padding: 6px 9px;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.canvas-tool-btn:disabled {
opacity: 0.35;
cursor: default;
}
.canvas-tool-btn:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.06));
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-tool-btn.is-active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.1));
color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.canvas-style-menu {
position: relative;
}
.canvas-style-menu__trigger:disabled {
opacity: 0.45;
}
.canvas-style-menu__panel {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 40;
min-width: 112px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: var(--dsw-alias-bg-layer-1, #1b1e28);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
gap: 2px;
}
.canvas-style-menu__item {
appearance: none;
border: none;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
font-size: 12px;
text-align: left;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
}
.canvas-style-menu__item:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-style-menu__item.is-active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.1));
color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.canvas-host.is-locked .canvas-tool-btn.is-active {
color: var(--dsw-alias-state-warning, #d4a017);
}
.canvas-minimap {
position: absolute;
right: 12px;
bottom: 12px;
width: 168px;
height: 118px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 88%,
transparent
);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
cursor: crosshair;
z-index: 3;
}
.canvas-minimap canvas {
display: block;
width: 100%;
height: 100%;
}
.trash-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
margin-bottom: 4px;
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.06));
}
.trash-item button {
padding: 4px 8px;
font-size: 12px;
}
.node-handles {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.node-handle {
position: absolute;
pointer-events: auto;
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid var(--dsw-alias-brand-primary, #5e6ad2);
background: var(--dsw-alias-bg-layer-1, #1b1e28);
color: var(--dsw-alias-brand-primary, #5e6ad2);
font-size: 14px;
line-height: 1;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.node-handle:hover {
background: var(--dsw-alias-brand-primary, #5e6ad2);
color: var(--dsw-alias-brand-text, #fff);
}
.hover-card {
position: absolute;
z-index: 5;
max-width: 280px;
min-width: 180px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer-1, #1b1e28) 92%,
transparent
);
backdrop-filter: blur(10px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 12px 36px rgba(0, 0, 0, 0.42);
pointer-events: none;
font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
}
.hover-card__title {
font-weight: 600;
font-size: 13.5px;
letter-spacing: 0.01em;
line-height: 1.35;
margin-bottom: 6px;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.hover-card__note {
font-size: 12px;
line-height: 1.45;
color: var(--dsw-alias-label-secondary, #a5adb8);
margin-bottom: 8px;
}
.hover-card__tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 6px;
}
.hover-card__tag {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--dsw-alias-label-secondary, #a5adb8);
}
.hover-card__meta {
font-size: 11px;
color: var(--dsw-alias-label-tertiary, #7b838f);
line-height: 1.35;
}
.edit-overlay {
position: absolute;
z-index: 6;
min-width: 140px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid var(--dsw-alias-brand-primary, #5e6ad2);
background: var(--dsw-alias-bg-layer-1, #1b1e28);
color: var(--dsw-alias-label-primary, #f4f5f7);
font: 600 13px "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui,
sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
outline: none;
}
.inspector {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 12px;
}
.inspector__title {
font-weight: 600;
font-size: 13px;
}
.inspector__field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary, #a5adb8);
}
.inspector__field input,
.inspector__field textarea,
.inspector__field select {
width: 100%;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.inspector__field textarea {
resize: vertical;
min-height: 56px;
}
.inspector__row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}

View file

@ -0,0 +1,137 @@
/** Read DSH / host CSS tokens (with dark fallbacks). */
export type SystemTheme = {
bgBase: string;
bgLayer1: string;
labelPrimary: string;
labelSecondary: string;
brand: string;
border: string;
bubble: string;
warn: string;
error: string;
};
const FALLBACK: SystemTheme = {
bgBase: "#101014",
bgLayer1: "#1b1e28",
labelPrimary: "#f4f5f7",
labelSecondary: "#a5adb8",
brand: "#5e6ad2",
border: "rgba(255,255,255,0.08)",
bubble: "rgba(37,42,58,0.9)",
warn: "#f59e5b",
error: "#ef4444",
};
const VAR_MAP: { key: keyof SystemTheme; css: string }[] = [
{ key: "bgBase", css: "--dsw-alias-bg-base" },
{ key: "bgLayer1", css: "--dsw-alias-bg-layer-1" },
{ key: "labelPrimary", css: "--dsw-alias-label-primary" },
{ key: "labelSecondary", css: "--dsw-alias-label-secondary" },
{ key: "brand", css: "--dsw-alias-brand-primary" },
{ key: "border", css: "--dsw-alias-border-l1" },
{ key: "bubble", css: "--dsw-specific-bubble" },
{ key: "warn", css: "--dsw-alias-state-warn-primary" },
{ key: "error", css: "--dsw-alias-state-error-primary" },
];
function cssVar(name: string): string {
try {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
} catch {
return "";
}
}
/** Apply theme tokens from query string (host → iframe). */
export function applyThemeFromQuery(search = window.location.search): void {
const q = new URLSearchParams(search);
const root = document.documentElement;
for (const { key, css } of VAR_MAP) {
const v = q.get(`t_${key}`);
if (v) root.style.setProperty(css, decodeURIComponent(v));
}
const scheme = q.get("colorScheme");
if (scheme === "light" || scheme === "dark") {
root.dataset.colorScheme = scheme;
}
}
export function readSystemTheme(): SystemTheme {
const out = { ...FALLBACK };
for (const { key, css } of VAR_MAP) {
const v = cssVar(css);
if (v) out[key] = v;
}
return out;
}
/** Snapshot DSH page tokens for embedding into iframe URL. */
export function snapshotHostTheme(): Record<string, string> {
const out: Record<string, string> = {};
for (const { key, css } of VAR_MAP) {
const v = cssVar(css);
if (v) out[`t_${key}`] = v;
}
try {
const scheme =
document.documentElement.dataset.colorScheme ||
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
out.colorScheme = scheme;
} catch {
out.colorScheme = "dark";
}
return out;
}
/** Simple branch hues from brand (same idea as dream-skin.branchPalette). */
export function branchPalette(brand: string, count = 8): string[] {
const hex = brand.startsWith("#") ? brand : FALLBACK.brand;
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
const n = m ? parseInt(m[1]!, 16) : 0x5e6ad2;
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
const max = Math.max(r, g, b) / 255;
const min = Math.min(r, g, b) / 255;
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const rr = r / 255;
const gg = g / 255;
const bb = b / 255;
switch (max) {
case rr:
h = ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6;
break;
case gg:
h = ((bb - rr) / d + 2) / 6;
break;
default:
h = ((rr - gg) / d + 4) / 6;
}
}
const out: string[] = [];
for (let i = 0; i < count; i++) {
const nh = (h * 360 + i * (360 / count)) % 360;
const ns = Math.min(0.55, s * 0.85);
const nl = Math.min(0.62, Math.max(0.42, l));
out.push(hslToHex(nh, ns, nl));
}
return out;
}
function hslToHex(h: number, s: number, l: number): string {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * c)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
}

11
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"]
}

14
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:17890",
"/health": "http://127.0.0.1:17890",
"/ws": { target: "ws://127.0.0.1:17890", ws: true },
},
},
});