feat: add PNG/SVG export and expandable card property tips

Toolbar exports PNG (fit+raster) and SVG; card click expands full fields, hover shows property tooltips, double-click opens the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 10:43:33 +08:00
parent 23ff27122f
commit 4c9bd5b86f
22 changed files with 978 additions and 315 deletions

View file

@ -390,6 +390,35 @@ export function startMindMapServer(
} }
}); });
app.get("/api/canvases/:id/export.svg", async (c) => {
try {
const id = c.req.param("id");
const result = (await runTool(
"mindmap_export",
{ format: "svg", canvasId: id },
{ store, broadcast, defaultCanvasId: id },
)) as {
ok?: boolean;
svg?: string;
title?: string;
};
if (!result?.ok || typeof result.svg !== "string") {
return c.json({ ok: false, error: "export_failed" }, 400);
}
const safe =
String(result.title ?? "mindmap").replace(/[\\/:*?"<>|]+/g, "_") ||
"mindmap";
return new Response(result.svg, {
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Content-Disposition": `attachment; filename="${safe}.svg"`,
},
});
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.post("/api/tools/:name", async (c) => { app.post("/api/tools/:name", async (c) => {
const name = c.req.param("name"); const name = c.req.param("name");
const args = (await c.req.json().catch(() => ({}))) as Record<string, unknown>; const args = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;

View file

@ -243,7 +243,7 @@ function escapeXml(s: string): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function exportSvg( export function exportSvg(
nodes: MindNode[], nodes: MindNode[],
edges: MindEdge[], edges: MindEdge[],
positions: Record<string, { x: number; y: number }>, positions: Record<string, { x: number; y: number }>,
@ -1108,24 +1108,37 @@ export async function runTool(
snap.canvas.title, snap.canvas.title,
anchorId, anchorId,
); );
if (format === "png") {
// Server has no font/raster stack; return SVG payload plus png mime hint.
// Web UI toolbar「PNG」rasterizes the live canvas (CJK-accurate).
return { return {
ok: true, ok: true,
format: format === "png" ? "svg" : "svg", format: "svg",
requestedFormat: "png",
title: snap.canvas.title, title: snap.canvas.title,
mimeType: "image/svg+xml", mimeType: "image/svg+xml",
svg, svg,
nodeCount: publicNodes.length, nodeCount: publicNodes.length,
edgeCount: publicEdges.length, edgeCount: publicEdges.length,
hint: hint:
format === "png" "PNG raster needs the web UI toolbar button (system fonts). This tool returns SVG which pastes into docs/PPT; save as .svg or convert locally.",
? "Agent export returns SVG (vector). For raster PNG use the web UI「导出PNG」; SVG pastes into docs/PPT as well." };
: "SVG can be saved as .svg or opened in browser / Office.", }
return {
ok: true,
format: "svg",
title: snap.canvas.title,
mimeType: "image/svg+xml",
svg,
nodeCount: publicNodes.length,
edgeCount: publicEdges.length,
hint: "SVG can be saved as .svg or opened in browser / Office.",
}; };
} }
return { return {
ok: false, ok: false,
error: "invalid_op", error: "invalid_op",
message: 'format must be "json" | "md" | "svg" (png → svg for agents; UI has raster PNG)', message: 'format must be "json" | "md" | "svg" | "png"',
}; };
} }

View file

@ -279,13 +279,48 @@ export function App() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const el = document.querySelector(".canvas-host canvas") as HTMLCanvasElement | null; const id = canvasId;
if (!id) return;
const title = (canvas?.title ?? "mindmap").replace(
/[\\/:*?"<>|]+/g,
"_",
);
void fetch(`/api/canvases/${id}/export.svg`)
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.text();
})
.then((svg) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(
new Blob([svg], { type: "image/svg+xml;charset=utf-8" }),
);
a.download = `${title}.svg`;
a.click();
})
.catch(() => {
/* ignore */
});
}}
>
导出SVG
</button>
<button
type="button"
onClick={() => {
const el = document.querySelector(
".canvas-host canvas",
) as HTMLCanvasElement | null;
if (!el) return; if (!el) return;
const title = (canvas?.title ?? "mindmap").replace(
/[\\/:*?"<>|]+/g,
"_",
);
el.toBlob((blob) => { el.toBlob((blob) => {
if (!blob) return; if (!blob) return;
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.png`; a.download = `${title}.png`;
a.click(); a.click();
}); });
}} }}

View file

@ -0,0 +1,20 @@
/** Floating property detail tip (card view hover). */
export function PropTooltip({
label,
value,
x,
y,
}: {
label: string;
value: string;
x: number;
y: number;
}) {
return (
<div className="prop-tooltip" style={{ left: x, top: y }} role="tooltip">
<div className="prop-tooltip__label">{label}</div>
<div className="prop-tooltip__value">{value}</div>
</div>
);
}

View file

@ -14,6 +14,11 @@ import {
type DragGhost, type DragGhost,
} from "./drag-subtree"; } from "./drag-subtree";
import { HoverCard } from "./HoverCard"; import { HoverCard } from "./HoverCard";
import { PropTooltip } from "./PropTooltip";
import {
exportFitPng,
exportSvgFromApi,
} from "./export-canvas";
import { getCachedImage } from "./image-cache"; import { getCachedImage } from "./image-cache";
import { import {
drawNodeShape, drawNodeShape,
@ -23,7 +28,12 @@ import {
TOPO_ICON_PX, TOPO_ICON_PX,
wrapTextLines, wrapTextLines,
} from "./node-geometry"; } from "./node-geometry";
import { drawPropSheet, NodePropOverlay } from "./node-props"; import {
cardFieldHits,
drawPropSheet,
NodePropOverlay,
type CardChipHit,
} from "./node-props";
import { branchPalette, readSystemTheme } from "./system-theme"; import { branchPalette, readSystemTheme } from "./system-theme";
import { useAppStore } from "./store"; import { useAppStore } from "./store";
import { useCanvasUi } from "./useCanvasUi"; import { useCanvasUi } from "./useCanvasUi";
@ -51,6 +61,7 @@ export function TopologyCanvas() {
const highlightEdgeIds = useAppStore((s) => s.highlightEdgeIds); const highlightEdgeIds = useAppStore((s) => s.highlightEdgeIds);
const highlightStyle = useAppStore((s) => s.highlightStyle); const highlightStyle = useAppStore((s) => s.highlightStyle);
const canvas = useAppStore((s) => s.canvas); const canvas = useAppStore((s) => s.canvas);
const canvasId = useAppStore((s) => s.canvasId);
const applyOps = useAppStore((s) => s.applyOps); const applyOps = useAppStore((s) => s.applyOps);
const setPrefs = useAppStore((s) => s.setPrefs); const setPrefs = useAppStore((s) => s.setPrefs);
const patchLocal = useAppStore((s) => s.patchLocal); const patchLocal = useAppStore((s) => s.patchLocal);
@ -124,6 +135,16 @@ export function TopologyCanvas() {
text: string; text: string;
} | null>(null); } | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null); const [hoverId, setHoverId] = useState<string | null>(null);
/** Card view: click-expanded node shows full field text on the face. */
const [expandedCardId, setExpandedCardId] = useState<string | null>(null);
/** Card view: double-click opens property editor overlay. */
const [propEditId, setPropEditId] = useState<string | null>(null);
const [propTip, setPropTip] = useState<{
label: string;
value: string;
x: number;
y: number;
} | null>(null);
const [linkPreview, setLinkPreview] = useState<{ const [linkPreview, setLinkPreview] = useState<{
fromId: string; fromId: string;
x: number; x: number;
@ -184,8 +205,12 @@ export function TopologyCanvas() {
const topoView = viewMode === "topology"; const topoView = viewMode === "topology";
const boxOf = useCallback( const boxOf = useCallback(
(n: (typeof nodes)[number]) => (n: (typeof nodes)[number]) =>
nodeBoxSize(n, dens, { asCard: cardView, asTopology: topoView }), nodeBoxSize(n, dens, {
[dens, cardView, topoView], asCard: cardView,
asTopology: topoView,
expanded: cardView && expandedCardId === n.id,
}),
[dens, cardView, topoView, expandedCardId],
); );
const nw = dens ? 160 : NODE_W; const nw = dens ? 160 : NODE_W;
@ -684,8 +709,8 @@ export function TopologyCanvas() {
ctx.globalAlpha = inGhost ? 0.7 : 1; ctx.globalAlpha = inGhost ? 0.7 : 1;
if (cardView) { if (cardView) {
const selected = selectedIds.includes(n.id); const editingProps = propEditId === n.id;
if (!selected) { if (!editingProps) {
drawPropSheet( drawPropSheet(
ctx, ctx,
n, n,
@ -696,6 +721,7 @@ export function TopologyCanvas() {
dens, dens,
fill, fill,
() => setImageTick((t) => t + 1), () => setImageTick((t) => t + 1),
{ expanded: expandedCardId === n.id },
); );
} else { } else {
// Title-only placeholder under HTML overlay (same flat surface) // Title-only placeholder under HTML overlay (same flat surface)
@ -812,6 +838,8 @@ export function TopologyCanvas() {
dens, dens,
cardView, cardView,
topoView, topoView,
expandedCardId,
propEditId,
boxOf, boxOf,
canvas?.prefs.showRelationEdges, canvas?.prefs.showRelationEdges,
canvas?.prefs.nodeBadges, canvas?.prefs.nodeBadges,
@ -950,6 +978,15 @@ export function TopologyCanvas() {
setDragCursor(null); setDragCursor(null);
return; return;
} }
if (e.key === "Escape") {
if (propEditId || expandedCardId || propTip) {
e.preventDefault();
setPropEditId(null);
setExpandedCardId(null);
setPropTip(null);
return;
}
}
if (locked) return; if (locked) return;
if (e.key === "Tab" && !locked) { if (e.key === "Tab" && !locked) {
@ -1035,6 +1072,11 @@ export function TopologyCanvas() {
viewport.zoom, viewport.zoom,
U, U,
withRouterIcon, withRouterIcon,
undo,
lastChangeSetId,
propEditId,
expandedCardId,
propTip,
]); ]);
const cursor = const cursor =
@ -1203,6 +1245,54 @@ export function TopologyCanvas() {
setDropForbidden(false); setDropForbidden(false);
} }
} }
// Idle hover: property tip in card view, node hover otherwise
if (dragRef.current.mode === "none" && !ghost) {
const hit = hitTest(world.x, world.y);
setHoverId(hit);
if (cardView && hit && viewport.zoom >= 0.35 && propEditId !== hit) {
const n = nodes.find((x) => x.id === hit);
const p = n ? posOf(hit) : null;
if (n && p) {
const { w: bw, h: bh } = boxOf(n);
const localX = world.x - (p.x - bw / 2);
const localY = world.y - (p.y - bh / 2);
const fields = cardFieldHits(
n,
dens,
expandedCardId === n.id,
);
const field = fields.find(
(f: CardChipHit) =>
localX >= f.x &&
localX <= f.x + f.w &&
localY >= f.y &&
localY <= f.y + f.h,
);
if (field) {
const host = hostRef.current?.getBoundingClientRect();
const tipX = host
? e.clientX - host.left + 14
: hostSize.w / 2 + viewport.x + world.x * viewport.zoom + 14;
const tipY = host
? e.clientY - host.top + 14
: hostSize.h / 2 + viewport.y + world.y * viewport.zoom + 14;
setPropTip({
label: field.label,
value: field.fullValue,
x: tipX,
y: tipY,
});
} else {
setPropTip(null);
}
} else {
setPropTip(null);
}
} else {
setPropTip(null);
}
}
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
const world = toWorld(e.clientX, e.clientY); const world = toWorld(e.clientX, e.clientY);
@ -1282,6 +1372,14 @@ export function TopologyCanvas() {
} }
} else if (mode === "node" && fromId && !moved) { } else if (mode === "node" && fromId && !moved) {
setSelection([fromId]); setSelection([fromId]);
if (cardView) {
setExpandedCardId((prev) => (prev === fromId ? null : fromId));
if (propEditId && propEditId !== fromId) setPropEditId(null);
}
} else if (mode === "pan" && !moved) {
setExpandedCardId(null);
setPropEditId(null);
setPropTip(null);
} }
dragRef.current.mode = "none"; dragRef.current.mode = "none";
@ -1300,6 +1398,13 @@ export function TopologyCanvas() {
const world = toWorld(e.clientX, e.clientY); const world = toWorld(e.clientX, e.clientY);
const hit = hitTest(world.x, world.y); const hit = hitTest(world.x, world.y);
if (hit) { if (hit) {
if (cardView) {
setSelection([hit]);
setExpandedCardId(hit);
setPropEditId(hit);
setPropTip(null);
return;
}
const n = nodes.find((x) => x.id === hit)!; const n = nodes.find((x) => x.id === hit)!;
const host = hostRef.current!.getBoundingClientRect(); const host = hostRef.current!.getBoundingClientRect();
const p = posOf(hit)!; const p = posOf(hit)!;
@ -1457,6 +1562,49 @@ export function TopologyCanvas() {
{U.undo} {U.undo}
</button> </button>
</div> </div>
<div className="canvas-toolbar__sep" />
<div className="canvas-toolbar__group">
<button
type="button"
className="canvas-tool-btn"
title={U.exportPngTitle}
onClick={() => {
const el = canvasRef.current;
if (!el) return;
const title = (canvas?.title ?? "mindmap").replace(
/[\\/:*?"<>|]+/g,
"_",
);
void exportFitPng({
fitView,
canvas: el,
filename: `${title}.png`,
}).catch(() => {
/* ignore */
});
}}
>
{U.exportPng}
</button>
<button
type="button"
className="canvas-tool-btn"
title={U.exportSvgTitle}
disabled={!canvasId}
onClick={() => {
if (!canvasId) return;
const title = (canvas?.title ?? "mindmap").replace(
/[\\/:*?"<>|]+/g,
"_",
);
void exportSvgFromApi(canvasId, `${title}.svg`).catch(() => {
/* ignore */
});
}}
>
{U.exportSvg}
</button>
</div>
<span className="canvas-toolbar__zoom"> <span className="canvas-toolbar__zoom">
{Math.round(viewport.zoom * 100)}% {Math.round(viewport.zoom * 100)}%
</span> </span>
@ -1629,13 +1777,22 @@ export function TopologyCanvas() {
return <HoverCard node={n} x={x} y={y} />; return <HoverCard node={n} x={x} y={y} />;
})()} })()}
{cardView && propTip ? (
<PropTooltip
label={propTip.label}
value={propTip.value}
x={propTip.x}
y={propTip.y}
/>
) : null}
{!locked && {!locked &&
!ghost && !ghost &&
cardView && cardView &&
selectedIds[0] && propEditId &&
viewport.zoom >= 0.35 && viewport.zoom >= 0.35 &&
(() => { (() => {
const id = selectedIds[0]!; const id = propEditId;
const n = nodes.find((x) => x.id === id); const n = nodes.find((x) => x.id === id);
if (!n || n.deletedAt) return null; if (!n || n.deletedAt) return null;
const p = positions[id]; const p = positions[id];

View file

@ -49,6 +49,10 @@ export type CanvasUi = {
hintLocked: string; hintLocked: string;
hintNodeDrag: string; hintNodeDrag: string;
hintBranchDrag: string; hintBranchDrag: string;
exportPng: string;
exportPngTitle: string;
exportSvg: string;
exportSvgTitle: string;
}; };
const zh: CanvasUi = { const zh: CanvasUi = {
@ -73,7 +77,7 @@ const zh: CanvasUi = {
card: "卡片", card: "卡片",
topology: "拓扑", topology: "拓扑",
bubbleTitle: "气泡视图:精简标题", bubbleTitle: "气泡视图:精简标题",
cardTitle: "卡片视图:展开属性面板", cardTitle: "卡片视图:单击展开全文 · 悬停属性看详情 · 双击编辑",
topologyTitle: "拓扑视图:路由器图标 + 链路", topologyTitle: "拓扑视图:路由器图标 + 链路",
unpin: "解钉", unpin: "解钉",
unpinTitle: "取消钉住:让选中节点重新参与自动布局", unpinTitle: "取消钉住:让选中节点重新参与自动布局",
@ -95,6 +99,10 @@ const zh: CanvasUi = {
hintLocked: "已锁定 · 仅浏览/缩放/平移", hintLocked: "已锁定 · 仅浏览/缩放/平移",
hintNodeDrag: "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消", hintNodeDrag: "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消",
hintBranchDrag: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消", hintBranchDrag: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消",
exportPng: "PNG",
exportPngTitle: "导出 PNG(先适应画布再截取)",
exportSvg: "SVG",
exportSvgTitle: "导出 SVG 矢量图",
}; };
const en: CanvasUi = { const en: CanvasUi = {
@ -119,7 +127,7 @@ const en: CanvasUi = {
card: "Card", card: "Card",
topology: "Topology", topology: "Topology",
bubbleTitle: "Bubble view: title only", bubbleTitle: "Bubble view: title only",
cardTitle: "Card view: property sheet", cardTitle: "Card view: click expand · hover props · double-click edit",
topologyTitle: "Topology view: router icons + links", topologyTitle: "Topology view: router icons + links",
unpin: "Unpin", unpin: "Unpin",
unpinTitle: "Unpin selected node for auto layout", unpinTitle: "Unpin selected node for auto layout",
@ -141,6 +149,10 @@ const en: CanvasUi = {
hintLocked: "Locked · browse / zoom / pan only", hintLocked: "Locked · browse / zoom / pan only",
hintNodeDrag: "Node drag · Shift temp node · Esc cancel", hintNodeDrag: "Node drag · Shift temp node · Esc cancel",
hintBranchDrag: "Branch drag · pin on blank · drop to reparent · N node · Esc cancel", hintBranchDrag: "Branch drag · pin on blank · drop to reparent · N node · Esc cancel",
exportPng: "PNG",
exportPngTitle: "Export PNG (fit view, then capture)",
exportSvg: "SVG",
exportSvgTitle: "Export SVG vector",
}; };
export const canvasUiTables = { zh, en } as const; export const canvasUiTables = { zh, en } as const;

View file

@ -0,0 +1,58 @@
/** Client-side canvas / file export helpers. */
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
window.setTimeout(() => URL.revokeObjectURL(url), 2_000);
}
export function downloadText(text: string, filename: string, mime: string) {
downloadBlob(new Blob([text], { type: mime }), filename);
}
/** Rasterize the on-screen canvas (current viewport) to PNG. */
export function exportCanvasPng(
el: HTMLCanvasElement,
filename: string,
): Promise<void> {
return new Promise((resolve, reject) => {
el.toBlob(
(blob) => {
if (!blob) {
reject(new Error("png_encode_failed"));
return;
}
downloadBlob(blob, filename);
resolve();
},
"image/png",
);
});
}
/** Fit view → wait for paint → PNG of full graph in viewport. */
export async function exportFitPng(opts: {
fitView: () => void;
canvas: HTMLCanvasElement;
filename: string;
}): Promise<void> {
opts.fitView();
await new Promise<void>((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r())),
);
await new Promise((r) => setTimeout(r, 40));
await exportCanvasPng(opts.canvas, opts.filename);
}
export async function exportSvgFromApi(
canvasId: string,
filename: string,
): Promise<void> {
const res = await fetch(`/api/canvases/${encodeURIComponent(canvasId)}/export.svg`);
if (!res.ok) throw new Error(`svg_export_${res.status}`);
const text = await res.text();
downloadText(text, filename, "image/svg+xml;charset=utf-8");
}

View file

@ -8,7 +8,7 @@ export const ROUTER_ICON_URL = "/topo/ne-router.png";
export function nodeBoxSize( export function nodeBoxSize(
n: MindNode, n: MindNode,
dens: boolean, dens: boolean,
opts?: { asCard?: boolean; asTopology?: boolean }, opts?: { asCard?: boolean; asTopology?: boolean; expanded?: boolean },
): { w: number; h: number } { ): { w: number; h: number } {
if (opts?.asTopology) { if (opts?.asTopology) {
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable; const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
@ -21,7 +21,7 @@ export function nodeBoxSize(
}; };
} }
if (opts?.asCard || n.stylePreset === "card") { if (opts?.asCard || n.stylePreset === "card") {
return estimateCardSize(n, dens); return estimateCardSize(n, dens, new Set(), { expanded: opts?.expanded });
} }
let w = dens ? 168 : 212; let w = dens ? 168 : 212;

View file

@ -4,6 +4,8 @@ export type DescChip = {
key: string; key: string;
label: string; label: string;
value: string; value: string;
/** Full value without truncation (for tooltips / expanded face). */
fullValue: string;
}; };
const LABEL_MAP: Record<string, string> = { const LABEL_MAP: Record<string, string> = {
@ -48,25 +50,27 @@ function truncate(s: string, max: number): string {
return `${t.slice(0, max - 1)}…`; return `${t.slice(0, max - 1)}…`;
} }
function formatValue(v: unknown): string | null { function formatValueRaw(v: unknown): string | null {
if (v == null) return null; if (v == null) return null;
if (typeof v === "string") { if (typeof v === "string") {
const t = v.trim(); const t = v.trim();
return t ? truncate(t, MAX_VALUE_LEN) : null; return t || null;
} }
if (typeof v === "number" || typeof v === "boolean") { if (typeof v === "number" || typeof v === "boolean") {
return truncate(String(v), MAX_VALUE_LEN); return String(v);
} }
if (Array.isArray(v)) { if (Array.isArray(v)) {
const parts = v const parts = v
.map((x) => (typeof x === "string" || typeof x === "number" ? String(x) : null)) .map((x) =>
typeof x === "string" || typeof x === "number" ? String(x) : null,
)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
if (!parts.length) return null; if (!parts.length) return null;
return truncate(parts.join("、"), MAX_VALUE_LEN); return parts.join("、");
} }
if (typeof v === "object") { if (typeof v === "object") {
try { try {
return truncate(JSON.stringify(v), MAX_VALUE_LEN); return JSON.stringify(v);
} catch { } catch {
return null; return null;
} }
@ -98,17 +102,28 @@ export function displayDescriptionKey(key: string): string {
return labelForKey(key); return labelForKey(key);
} }
export type DescriptionChipsOpts = {
max?: number;
/** When true, `value` is not truncated (same as fullValue). */
full?: boolean;
};
/** Flatten description object into labeled chips for card / editor face. */ /** Flatten description object into labeled chips for card / editor face. */
export function descriptionChips( export function descriptionChips(
description: Record<string, unknown> | null | undefined, description: Record<string, unknown> | null | undefined,
max = MAX_CHIPS, maxOrOpts: number | DescriptionChipsOpts = MAX_CHIPS,
): DescChip[] { ): DescChip[] {
const opts: DescriptionChipsOpts =
typeof maxOrOpts === "number" ? { max: maxOrOpts } : maxOrOpts;
const max = opts.max ?? (opts.full ? 64 : MAX_CHIPS);
const full = Boolean(opts.full);
if (!description || typeof description !== "object") return []; if (!description || typeof description !== "object") return [];
const out: DescChip[] = []; const out: DescChip[] = [];
for (const [key, raw] of Object.entries(description)) { for (const [key, raw] of Object.entries(description)) {
const value = formatValue(raw); const fullValue = formatValueRaw(raw);
if (!value) continue; if (!fullValue) continue;
out.push({ key, label: labelForKey(key), value }); const value = full ? fullValue : truncate(fullValue, MAX_VALUE_LEN);
out.push({ key, label: labelForKey(key), value, fullValue });
if (out.length >= max) break; if (out.length >= max) break;
} }
return out; return out;

View file

@ -3,9 +3,10 @@ import { canvasFont, paintBadgeChip } from "../canvas-chrome";
import { getCachedImage, imageLoadFailed } from "../image-cache"; import { getCachedImage, imageLoadFailed } from "../image-cache";
import type { SystemTheme } from "../system-theme"; import type { SystemTheme } from "../system-theme";
import { roundRectPath, wrapTextLines } from "../node-geometry"; import { roundRectPath, wrapTextLines } from "../node-geometry";
import { descriptionChips } from "./description-display"; import { descriptionChips, type DescChip } from "./description-display";
import { import {
CARD_CHIP_H, CARD_CHIP_H,
CARD_FOOTER_H,
CARD_GAP, CARD_GAP,
CARD_HEADER_H, CARD_HEADER_H,
CARD_HERO_H, CARD_HERO_H,
@ -31,6 +32,178 @@ function formatDate(ms?: number): string | null {
} }
} }
export type CardChipHit = {
key: string;
label: string;
value: string;
fullValue: string;
/** Card-local rect (relative to card top-left). */
x: number;
y: number;
w: number;
h: number;
};
export type DrawPropSheetOpts = {
expanded?: boolean;
};
/** Compute chip hit targets in card-local coordinates (must match drawPropSheet). */
export function cardChipHits(
node: MindNode,
dens: boolean,
expanded = false,
): CardChipHit[] {
const { w } = estimateCardSize(node, dens, new Set(), { expanded });
let cy = CARD_HEADER_H + Math.max(4, CARD_PAD_Y - 4);
const contentW = w - CARD_PAD_X * 2;
if (node.imageUrl?.trim()) {
cy += (dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable) + CARD_GAP;
}
const note = node.note?.trim();
if (note) {
const maxLines = expanded ? (dens ? 10 : 12) : dens ? 2 : 3;
// Approximate line count without canvas measure (hit test is generous)
const approxChars = dens ? 28 : 34;
const lines = Math.min(
maxLines,
Math.max(1, Math.ceil(note.length / approxChars)),
);
const lh = dens ? 15 : 16;
cy += lines * lh + CARD_GAP;
}
const chips = descriptionChips(node.description, {
full: expanded,
max: expanded ? 64 : 6,
});
const hits: CardChipHit[] = [];
const rowGap = dens ? 4 : 5;
for (const chip of chips) {
const approx = dens ? 18 : 22;
const vLines = expanded
? Math.min(8, Math.max(1, Math.ceil(chip.fullValue.length / approx)))
: 1;
const lh = dens ? 14 : 15;
const rowH = CARD_CHIP_H + Math.max(0, vLines - 1) * lh;
hits.push({
key: chip.key,
label: chip.label,
value: chip.value,
fullValue: chip.fullValue,
x: CARD_PAD_X,
y: cy,
w: contentW,
h: rowH,
});
cy += rowH + rowGap;
}
return hits;
}
/** Also expose note / title as hoverable regions when truncated. */
export function cardFieldHits(
node: MindNode,
dens: boolean,
expanded = false,
): CardChipHit[] {
const { w } = estimateCardSize(node, dens, new Set(), { expanded });
const hits: CardChipHit[] = [];
const title = (node.text || "未命名").trim();
hits.push({
key: "__title",
label: "标题",
value: title,
fullValue: title,
x: 28,
y: 4,
w: w - 32 - CARD_PAD_X,
h: CARD_HEADER_H - 8,
});
let cy = CARD_HEADER_H + Math.max(4, CARD_PAD_Y - 4);
const contentW = w - CARD_PAD_X * 2;
if (node.imageUrl?.trim()) {
cy += (dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable) + CARD_GAP;
}
const note = node.note?.trim();
if (note) {
const maxLines = expanded ? (dens ? 10 : 12) : dens ? 2 : 3;
const approxChars = dens ? 28 : 34;
const lines = Math.min(
maxLines,
Math.max(1, Math.ceil(note.length / approxChars)),
);
const lh = dens ? 15 : 16;
const noteH = lines * lh;
hits.push({
key: "__note",
label: "摘要",
value: note,
fullValue: note,
x: CARD_PAD_X,
y: cy,
w: contentW,
h: noteH,
});
}
return [...hits, ...cardChipHits(node, dens, expanded)];
}
function drawChipRows(
ctx: CanvasRenderingContext2D,
chips: DescChip[],
x: number,
startY: number,
contentW: number,
theme: SystemTheme,
dens: boolean,
expanded: boolean,
): number {
let cy = startY;
const rowGap = dens ? 4 : 5;
for (let i = 0; i < chips.length; i++) {
const chip = chips[i]!;
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(10, 500);
ctx.textBaseline = "middle";
const label = chip.label;
const labelW = Math.min(ctx.measureText(label).width, contentW * 0.36);
const text = expanded ? chip.fullValue : chip.value;
const vx = x + CARD_PAD_X + labelW + 10;
const vw = contentW - labelW - 10;
const maxLines = expanded ? 8 : 1;
ctx.font = canvasFont(dens ? 11 : 12, 500);
const vLines = wrapTextLines(ctx, text, vw, maxLines);
const lh = dens ? 14 : 15;
const rowH = Math.max(CARD_CHIP_H, vLines.length * lh);
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(10, 500);
ctx.textBaseline = "middle";
ctx.fillText(label, x + CARD_PAD_X, cy + CARD_CHIP_H / 2, labelW);
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 11 : 12, 500);
if (vLines.length <= 1) {
ctx.textBaseline = "middle";
ctx.fillText(vLines[0] ?? text, vx, cy + CARD_CHIP_H / 2);
} else {
ctx.textBaseline = "top";
for (let li = 0; li < vLines.length; li++) {
ctx.fillText(vLines[li]!, vx, cy + 2 + li * lh);
}
}
cy += rowH + rowGap;
}
return cy;
}
/** Glass card body: title → hero → note → meta chips → footer (no raw JSON). */ /** Glass card body: title → hero → note → meta chips → footer (no raw JSON). */
export function drawPropSheet( export function drawPropSheet(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
@ -42,8 +215,10 @@ export function drawPropSheet(
dens: boolean, dens: boolean,
accent: string, accent: string,
onImage: () => void, onImage: () => void,
opts: DrawPropSheetOpts = {},
) { ) {
const { w, h } = estimateCardSize(node, dens); const expanded = Boolean(opts.expanded);
const { w, h } = estimateCardSize(node, dens, new Set(), { expanded });
const radius = dens ? 14 : 16; const radius = dens ? 14 : 16;
ctx.save(); ctx.save();
@ -91,13 +266,13 @@ export function drawPropSheet(
ctx, ctx,
node.text || "未命名", node.text || "未命名",
titleMaxW, titleMaxW,
dens ? 1 : 2, expanded ? (dens ? 2 : 3) : dens ? 1 : 2,
); );
if (titleLines.length === 1) { if (titleLines.length === 1) {
ctx.fillText(titleLines[0]!, x + 28, y + CARD_HEADER_H / 2); ctx.fillText(titleLines[0]!, x + 28, y + CARD_HEADER_H / 2);
} else { } else {
const lh = 14; const lh = 14;
const ty = y + CARD_HEADER_H / 2 - lh / 2; const ty = y + CARD_HEADER_H / 2 - ((titleLines.length - 1) * lh) / 2;
titleLines.forEach((line, i) => { titleLines.forEach((line, i) => {
ctx.fillText(line, x + 28, ty + i * lh); ctx.fillText(line, x + 28, ty + i * lh);
}); });
@ -118,7 +293,6 @@ export function drawPropSheet(
ctx.fillStyle = theme.glassHeader; ctx.fillStyle = theme.glassHeader;
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih); ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
if (img && img.naturalWidth > 0) { if (img && img.naturalWidth > 0) {
// Cover: fill frame, crop edges lightly for magazine feel
const scale = Math.max( const scale = Math.max(
contentW / img.naturalWidth, contentW / img.naturalWidth,
ih / img.naturalHeight, ih / img.naturalHeight,
@ -152,7 +326,8 @@ export function drawPropSheet(
ctx.fillStyle = theme.labelSecondary; ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(dens ? 11.5 : 12.5, 400); ctx.font = canvasFont(dens ? 11.5 : 12.5, 400);
ctx.textBaseline = "top"; ctx.textBaseline = "top";
const lines = wrapTextLines(ctx, note, contentW, dens ? 2 : 3); const maxLines = expanded ? (dens ? 10 : 12) : dens ? 2 : 3;
const lines = wrapTextLines(ctx, note, contentW, maxLines);
const lh = dens ? 15 : 16; const lh = dens ? 15 : 16;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i]!, x + CARD_PAD_X, cy + i * lh); ctx.fillText(lines[i]!, x + CARD_PAD_X, cy + i * lh);
@ -160,34 +335,20 @@ export function drawPropSheet(
cy += lines.length * lh + CARD_GAP; cy += lines.length * lh + CARD_GAP;
} }
// Description as one compact key-value block (no row dividers) // Description as key-value rows
const chips = descriptionChips(node.description); const chips = descriptionChips(node.description, {
full: expanded,
max: expanded ? 64 : 6,
});
if (chips.length) { if (chips.length) {
const rowGap = dens ? 4 : 5; cy = drawChipRows(ctx, chips, x, cy, contentW, theme, dens, expanded);
for (let i = 0; i < chips.length; i++) {
const chip = chips[i]!;
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(10, 500);
ctx.textBaseline = "middle";
const label = chip.label;
const labelW = Math.min(ctx.measureText(label).width, contentW * 0.36);
ctx.fillText(label, x + CARD_PAD_X, cy + CARD_CHIP_H / 2, labelW);
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 11 : 12, 500);
const vx = x + CARD_PAD_X + labelW + 10;
const vw = contentW - labelW - 10;
const vLines = wrapTextLines(ctx, chip.value, vw, 1);
ctx.fillText(vLines[0] ?? chip.value, vx, cy + CARD_CHIP_H / 2);
cy += CARD_CHIP_H + rowGap;
}
cy += CARD_GAP - 4; cy += CARD_GAP - 4;
} }
// Footer: tags / source / dates as chips // Footer: tags / source / dates as chips
const footerBits: string[] = []; const footerBits: string[] = [];
if (node.tags?.length) { if (node.tags?.length) {
for (const t of node.tags.slice(0, 3)) footerBits.push(`#${t}`); for (const t of node.tags.slice(0, expanded ? 12 : 3)) footerBits.push(`#${t}`);
} }
if (node.links?.[0]) { if (node.links?.[0]) {
const l = node.links[0]; const l = node.links[0];
@ -205,7 +366,11 @@ export function drawPropSheet(
for (const bit of footerBits) { for (const bit of footerBits) {
const used = paintBadgeChip(ctx, bit, fx, fy, theme, zoom); const used = paintBadgeChip(ctx, bit, fx, fy, theme, zoom);
fx += used; fx += used;
if (fx > x + w - CARD_PAD_X - 24) break; if (!expanded && fx > x + w - CARD_PAD_X - 24) break;
if (expanded && fx > x + w - CARD_PAD_X - 24) {
fx = x + CARD_PAD_X;
cy += CARD_FOOTER_H;
}
} }
} }

View file

@ -1,6 +1,7 @@
export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry"; export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry";
export { estimateCardSize, CARD_WIDTH } from "./sizing"; export { estimateCardSize, CARD_WIDTH } from "./sizing";
export { drawPropSheet } from "./draw-sheet"; export { drawPropSheet, cardChipHits, cardFieldHits } from "./draw-sheet";
export type { CardChipHit } from "./draw-sheet";
export { descriptionChips, descriptionPreviewLine } from "./description-display"; export { descriptionChips, descriptionPreviewLine } from "./description-display";
export { NodePropEditor, NodePropOverlay } from "./NodePropEditor"; export { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
export type { PropFieldId, PropFieldDef } from "./types"; export type { PropFieldId, PropFieldDef } from "./types";

View file

@ -134,6 +134,14 @@
min-width: 0; min-width: 0;
} }
.ns-chip__v.is-expanded,
.ns-editor--expanded .ns-chip__v {
white-space: normal;
overflow: visible;
text-overflow: unset;
word-break: break-word;
}
.ns-kv { .ns-kv {
margin: 4px 2px 6px; margin: 4px 2px 6px;
padding: 4px 2px 6px; padding: 4px 2px 6px;

View file

@ -10,12 +10,19 @@ export const CARD_HERO_H = { comfortable: 108, compact: 90 } as const;
export const CARD_CHIP_H = 18; export const CARD_CHIP_H = 18;
export const CARD_FOOTER_H = 24; export const CARD_FOOTER_H = 24;
export type CardSizeOpts = {
/** Click-expanded: show full note / multi-line props / more chips. */
expanded?: boolean;
};
/** Layout / hit-test height for a glass card node (title → image → note → chips → footer). */ /** Layout / hit-test height for a glass card node (title → image → note → chips → footer). */
export function estimateCardSize( export function estimateCardSize(
node: MindNode, node: MindNode,
dens: boolean, dens: boolean,
_forced: ReadonlySet<string> = new Set(), _forced: ReadonlySet<string> = new Set(),
opts: CardSizeOpts = {},
): { w: number; h: number } { ): { w: number; h: number } {
const expanded = Boolean(opts.expanded);
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable; const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
let body = 0; let body = 0;
@ -24,12 +31,42 @@ export function estimateCardSize(
} }
if (node.note?.trim()) { if (node.note?.trim()) {
if (expanded) {
// Rough multi-line note: ~42 chars/line at card width
const lines = Math.min(
12,
Math.max(2, Math.ceil(node.note.trim().length / (dens ? 28 : 34))),
);
body += lines * (dens ? 15 : 16) + CARD_GAP;
} else {
body += (dens ? 40 : 48) + CARD_GAP; body += (dens ? 40 : 48) + CARD_GAP;
} }
}
const chips = descriptionChips(node.description); const chips = descriptionChips(node.description, {
full: expanded,
max: expanded ? 64 : 6,
});
if (chips.length) { if (chips.length) {
body += chips.length * CARD_CHIP_H + Math.max(0, chips.length - 1) * 4 + CARD_GAP; if (expanded) {
const rowGap = dens ? 4 : 5;
let chipH = 0;
for (const chip of chips) {
// ~22 chars per value line when label takes ~36% width
const vLines = Math.min(
8,
Math.max(1, Math.ceil(chip.fullValue.length / (dens ? 18 : 22))),
);
chipH += CARD_CHIP_H + Math.max(0, vLines - 1) * (dens ? 14 : 15);
chipH += rowGap;
}
body += chipH + CARD_GAP;
} else {
body +=
chips.length * CARD_CHIP_H +
Math.max(0, chips.length - 1) * 4 +
CARD_GAP;
}
} }
const hasFooter = const hasFooter =

View file

@ -506,6 +506,45 @@ input {
line-height: 1.35; line-height: 1.35;
} }
.prop-tooltip {
position: absolute;
z-index: 8;
max-width: 320px;
min-width: 140px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer-1, #1b1e28) 94%,
transparent
);
backdrop-filter: blur(12px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 14px 40px rgba(0, 0, 0, 0.48);
pointer-events: none;
font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
}
.prop-tooltip__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary, #a5adb8);
margin-bottom: 4px;
}
.prop-tooltip__value {
font-size: 12.5px;
font-weight: 500;
line-height: 1.45;
color: var(--dsw-alias-label-primary, #f4f5f7);
white-space: pre-wrap;
word-break: break-word;
}
.edit-overlay { .edit-overlay {
position: absolute; position: absolute;
z-index: 6; z-index: 6;

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind Map</title> <title>Mind Map</title>
<script type="module" crossorigin src="/assets/index-iTIYRavW.js"></script> <script type="module" crossorigin src="/assets/index-B_0PHR0v.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BK6FV7kG.css"> <link rel="stylesheet" crossorigin href="/assets/index-CACgBf2I.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -265,7 +265,7 @@ export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActive
{ {
name: 'mindmap_export', name: 'mindmap_export',
description: description:
'Export canvas as json | md | svg. format=png also returns SVG (vector) for agents; use web UI for raster PNG.', 'Export canvas as json | md | svg | png. format=png returns SVG for agents (no server fonts); use the canvas toolbar PNG button for raster PNG.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@ -674,7 +674,7 @@ Other tools:
- mindmap_query — graph Q&A: neighbors|path|stats|orphans|missing_meta (deterministic; cite returned nodes) - mindmap_query — graph Q&A: neighbors|path|stats|orphans|missing_meta (deterministic; cite returned nodes)
- mindmap_get_node / mindmap_get_edge — details for follow-up - mindmap_get_node / mindmap_get_edge — details for follow-up
- mindmap_highlight — flash path (nodeIds + edgeIds + optional ttlMs/style) - mindmap_highlight — flash path (nodeIds + edgeIds + optional ttlMs/style)
- mindmap_export — format json|md|svg (png→svg for agents; UI has raster PNG) - mindmap_export — format json|md|svg|png (png→svg for agents; toolbar PNG is raster)
- mindmap_focus, mindmap_undo, mindmap_redo, mindmap_history - mindmap_focus, mindmap_undo, mindmap_redo, mindmap_history
- mindmap_snapshot / mindmap_restore_snapshot — named checkpoints - mindmap_snapshot / mindmap_restore_snapshot — named checkpoints
- mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist - mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist