mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 00:43:20 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
32
apps/server/package.json
Normal file
32
apps/server/package.json
Normal 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
206
apps/server/src/chat.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
736
apps/server/src/create-server.ts
Normal file
736
apps/server/src/create-server.ts
Normal 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
3
apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { startMindMapServer } from "./create-server.js";
|
||||
|
||||
startMindMapServer();
|
||||
2005
apps/server/src/tools.ts
Normal file
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
16
apps/server/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
1
apps/server/tsconfig.tsbuildinfo
Normal file
1
apps/server/tsconfig.tsbuildinfo
Normal 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
12
apps/web/index.html
Normal 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
28
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/scripts/_check_cjk.py
Normal file
6
apps/web/scripts/_check_cjk.py
Normal 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)
|
||||
15
apps/web/scripts/_fix_leftover.py
Normal file
15
apps/web/scripts/_fix_leftover.py
Normal 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)))
|
||||
13
apps/web/scripts/_inspect_cjk.py
Normal file
13
apps/web/scripts/_inspect_cjk.py
Normal 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)))
|
||||
24
apps/web/scripts/check-cjk-dist.py
Normal file
24
apps/web/scripts/check-cjk-dist.py
Normal 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))
|
||||
207
apps/web/scripts/fix-topology-cjk.py
Normal file
207
apps/web/scripts/fix-topology-cjk.py
Normal 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)
|
||||
115
apps/web/scripts/fix-topology-cjk2.py
Normal file
115
apps/web/scripts/fix-topology-cjk2.py
Normal 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)
|
||||
221
apps/web/scripts/fix-topology-cjk3.py
Normal file
221
apps/web/scripts/fix-topology-cjk3.py
Normal 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)
|
||||
22
apps/web/scripts/fix-topology-cjk4.py
Normal file
22
apps/web/scripts/fix-topology-cjk4.py
Normal 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")
|
||||
214
apps/web/scripts/fix-topology-cjk5.py
Normal file
214
apps/web/scripts/fix-topology-cjk5.py
Normal 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")
|
||||
10
apps/web/scripts/verify-dist-cjk.py
Normal file
10
apps/web/scripts/verify-dist-cjk.py
Normal 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)
|
||||
14
apps/web/scripts/verify-topology-cjk.py
Normal file
14
apps/web/scripts/verify-topology-cjk.py
Normal 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
346
apps/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/HoverCard.tsx
Normal file
37
apps/web/src/HoverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/NodeInspector.tsx
Normal file
197
apps/web/src/NodeInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1581
apps/web/src/TopologyCanvas.tsx
Normal file
1581
apps/web/src/TopologyCanvas.tsx
Normal file
File diff suppressed because it is too large
Load diff
104
apps/web/src/canvas-chrome.ts
Normal file
104
apps/web/src/canvas-chrome.ts
Normal 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;
|
||||
}
|
||||
56
apps/web/src/drag-subtree.ts
Normal file
56
apps/web/src/drag-subtree.ts
Normal 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;
|
||||
}
|
||||
31
apps/web/src/image-cache.ts
Normal file
31
apps/web/src/image-cache.ts
Normal 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
5
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
180
apps/web/src/node-geometry.ts
Normal file
180
apps/web/src/node-geometry.ts
Normal 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: "[球]",
|
||||
};
|
||||
411
apps/web/src/node-props/NodePropEditor.tsx
Normal file
411
apps/web/src/node-props/NodePropEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
apps/web/src/node-props/draw-sheet.ts
Normal file
162
apps/web/src/node-props/draw-sheet.ts
Normal 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();
|
||||
}
|
||||
5
apps/web/src/node-props/index.ts
Normal file
5
apps/web/src/node-props/index.ts
Normal 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";
|
||||
311
apps/web/src/node-props/node-shell.css
Normal file
311
apps/web/src/node-props/node-shell.css
Normal 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;
|
||||
}
|
||||
283
apps/web/src/node-props/registry.ts
Normal file
283
apps/web/src/node-props/registry.ts
Normal 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);
|
||||
}
|
||||
29
apps/web/src/node-props/sizing.ts
Normal file
29
apps/web/src/node-props/sizing.ts
Normal 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) };
|
||||
}
|
||||
41
apps/web/src/node-props/types.ts
Normal file
41
apps/web/src/node-props/types.ts
Normal 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;
|
||||
};
|
||||
77
apps/web/src/node-props/upload-image.ts
Normal file
77
apps/web/src/node-props/upload-image.ts
Normal 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
378
apps/web/src/store.ts
Normal 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
560
apps/web/src/styles.css
Normal 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;
|
||||
}
|
||||
137
apps/web/src/system-theme.ts
Normal file
137
apps/web/src/system-theme.ts
Normal 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
11
apps/web/tsconfig.json
Normal 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
14
apps/web/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue