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) => {
const name = c.req.param("name");
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;");
}
function exportSvg(
export function exportSvg(
nodes: MindNode[],
edges: MindEdge[],
positions: Record<string, { x: number; y: number }>,
@ -1108,24 +1108,37 @@ export async function runTool(
snap.canvas.title,
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 {
ok: true,
format: "svg",
requestedFormat: "png",
title: snap.canvas.title,
mimeType: "image/svg+xml",
svg,
nodeCount: publicNodes.length,
edgeCount: publicEdges.length,
hint:
"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.",
};
}
return {
ok: true,
format: format === "png" ? "svg" : "svg",
format: "svg",
title: snap.canvas.title,
mimeType: "image/svg+xml",
svg,
nodeCount: publicNodes.length,
edgeCount: publicEdges.length,
hint:
format === "png"
? "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.",
hint: "SVG can be saved as .svg or opened in browser / Office.",
};
}
return {
ok: false,
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
type="button"
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;
const title = (canvas?.title ?? "mindmap").replace(
/[\\/:*?"<>|]+/g,
"_",
);
el.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.png`;
a.download = `${title}.png`;
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,
} from "./drag-subtree";
import { HoverCard } from "./HoverCard";
import { PropTooltip } from "./PropTooltip";
import {
exportFitPng,
exportSvgFromApi,
} from "./export-canvas";
import { getCachedImage } from "./image-cache";
import {
drawNodeShape,
@ -23,7 +28,12 @@ import {
TOPO_ICON_PX,
wrapTextLines,
} 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 { useAppStore } from "./store";
import { useCanvasUi } from "./useCanvasUi";
@ -51,6 +61,7 @@ export function TopologyCanvas() {
const highlightEdgeIds = useAppStore((s) => s.highlightEdgeIds);
const highlightStyle = useAppStore((s) => s.highlightStyle);
const canvas = useAppStore((s) => s.canvas);
const canvasId = useAppStore((s) => s.canvasId);
const applyOps = useAppStore((s) => s.applyOps);
const setPrefs = useAppStore((s) => s.setPrefs);
const patchLocal = useAppStore((s) => s.patchLocal);
@ -124,6 +135,16 @@ export function TopologyCanvas() {
text: 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<{
fromId: string;
x: number;
@ -184,8 +205,12 @@ export function TopologyCanvas() {
const topoView = viewMode === "topology";
const boxOf = useCallback(
(n: (typeof nodes)[number]) =>
nodeBoxSize(n, dens, { asCard: cardView, asTopology: topoView }),
[dens, cardView, topoView],
nodeBoxSize(n, dens, {
asCard: cardView,
asTopology: topoView,
expanded: cardView && expandedCardId === n.id,
}),
[dens, cardView, topoView, expandedCardId],
);
const nw = dens ? 160 : NODE_W;
@ -684,8 +709,8 @@ export function TopologyCanvas() {
ctx.globalAlpha = inGhost ? 0.7 : 1;
if (cardView) {
const selected = selectedIds.includes(n.id);
if (!selected) {
const editingProps = propEditId === n.id;
if (!editingProps) {
drawPropSheet(
ctx,
n,
@ -696,6 +721,7 @@ export function TopologyCanvas() {
dens,
fill,
() => setImageTick((t) => t + 1),
{ expanded: expandedCardId === n.id },
);
} else {
// Title-only placeholder under HTML overlay (same flat surface)
@ -812,6 +838,8 @@ export function TopologyCanvas() {
dens,
cardView,
topoView,
expandedCardId,
propEditId,
boxOf,
canvas?.prefs.showRelationEdges,
canvas?.prefs.nodeBadges,
@ -950,6 +978,15 @@ export function TopologyCanvas() {
setDragCursor(null);
return;
}
if (e.key === "Escape") {
if (propEditId || expandedCardId || propTip) {
e.preventDefault();
setPropEditId(null);
setExpandedCardId(null);
setPropTip(null);
return;
}
}
if (locked) return;
if (e.key === "Tab" && !locked) {
@ -1035,6 +1072,11 @@ export function TopologyCanvas() {
viewport.zoom,
U,
withRouterIcon,
undo,
lastChangeSetId,
propEditId,
expandedCardId,
propTip,
]);
const cursor =
@ -1203,6 +1245,54 @@ export function TopologyCanvas() {
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) => {
const world = toWorld(e.clientX, e.clientY);
@ -1282,6 +1372,14 @@ export function TopologyCanvas() {
}
} else if (mode === "node" && fromId && !moved) {
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";
@ -1300,6 +1398,13 @@ export function TopologyCanvas() {
const world = toWorld(e.clientX, e.clientY);
const hit = hitTest(world.x, world.y);
if (hit) {
if (cardView) {
setSelection([hit]);
setExpandedCardId(hit);
setPropEditId(hit);
setPropTip(null);
return;
}
const n = nodes.find((x) => x.id === hit)!;
const host = hostRef.current!.getBoundingClientRect();
const p = posOf(hit)!;
@ -1457,6 +1562,49 @@ export function TopologyCanvas() {
{U.undo}
</button>
</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">
{Math.round(viewport.zoom * 100)}%
</span>
@ -1629,13 +1777,22 @@ export function TopologyCanvas() {
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 &&
!ghost &&
cardView &&
selectedIds[0] &&
propEditId &&
viewport.zoom >= 0.35 &&
(() => {
const id = selectedIds[0]!;
const id = propEditId;
const n = nodes.find((x) => x.id === id);
if (!n || n.deletedAt) return null;
const p = positions[id];

View file

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

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(
n: MindNode,
dens: boolean,
opts?: { asCard?: boolean; asTopology?: boolean },
opts?: { asCard?: boolean; asTopology?: boolean; expanded?: boolean },
): { w: number; h: number } {
if (opts?.asTopology) {
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
@ -21,7 +21,7 @@ export function nodeBoxSize(
};
}
if (opts?.asCard || n.stylePreset === "card") {
return estimateCardSize(n, dens);
return estimateCardSize(n, dens, new Set(), { expanded: opts?.expanded });
}
let w = dens ? 168 : 212;

View file

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

View file

@ -3,9 +3,10 @@ import { canvasFont, paintBadgeChip } from "../canvas-chrome";
import { getCachedImage, imageLoadFailed } from "../image-cache";
import type { SystemTheme } from "../system-theme";
import { roundRectPath, wrapTextLines } from "../node-geometry";
import { descriptionChips } from "./description-display";
import { descriptionChips, type DescChip } from "./description-display";
import {
CARD_CHIP_H,
CARD_FOOTER_H,
CARD_GAP,
CARD_HEADER_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). */
export function drawPropSheet(
ctx: CanvasRenderingContext2D,
@ -42,8 +215,10 @@ export function drawPropSheet(
dens: boolean,
accent: string,
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;
ctx.save();
@ -91,13 +266,13 @@ export function drawPropSheet(
ctx,
node.text || "未命名",
titleMaxW,
dens ? 1 : 2,
expanded ? (dens ? 2 : 3) : dens ? 1 : 2,
);
if (titleLines.length === 1) {
ctx.fillText(titleLines[0]!, x + 28, y + CARD_HEADER_H / 2);
} else {
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) => {
ctx.fillText(line, x + 28, ty + i * lh);
});
@ -118,7 +293,6 @@ export function drawPropSheet(
ctx.fillStyle = theme.glassHeader;
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
if (img && img.naturalWidth > 0) {
// Cover: fill frame, crop edges lightly for magazine feel
const scale = Math.max(
contentW / img.naturalWidth,
ih / img.naturalHeight,
@ -152,7 +326,8 @@ export function drawPropSheet(
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(dens ? 11.5 : 12.5, 400);
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;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i]!, x + CARD_PAD_X, cy + i * lh);
@ -160,34 +335,20 @@ export function drawPropSheet(
cy += lines.length * lh + CARD_GAP;
}
// Description as one compact key-value block (no row dividers)
const chips = descriptionChips(node.description);
// Description as key-value rows
const chips = descriptionChips(node.description, {
full: expanded,
max: expanded ? 64 : 6,
});
if (chips.length) {
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);
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 = drawChipRows(ctx, chips, x, cy, contentW, theme, dens, expanded);
cy += CARD_GAP - 4;
}
// Footer: tags / source / dates as chips
const footerBits: string[] = [];
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]) {
const l = node.links[0];
@ -205,7 +366,11 @@ export function drawPropSheet(
for (const bit of footerBits) {
const used = paintBadgeChip(ctx, bit, fx, fy, theme, zoom);
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 { 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 { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
export type { PropFieldId, PropFieldDef } from "./types";

View file

@ -134,6 +134,14 @@
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 {
margin: 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_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). */
export function estimateCardSize(
node: MindNode,
dens: boolean,
_forced: ReadonlySet<string> = new Set(),
opts: CardSizeOpts = {},
): { w: number; h: number } {
const expanded = Boolean(opts.expanded);
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
let body = 0;
@ -24,12 +31,42 @@ export function estimateCardSize(
}
if (node.note?.trim()) {
body += (dens ? 40 : 48) + CARD_GAP;
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;
}
}
const chips = descriptionChips(node.description);
const chips = descriptionChips(node.description, {
full: expanded,
max: expanded ? 64 : 6,
});
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 =

View file

@ -506,6 +506,45 @@ input {
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 {
position: absolute;
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind Map</title>
<script type="module" crossorigin src="/assets/index-iTIYRavW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BK6FV7kG.css">
<script type="module" crossorigin src="/assets/index-B_0PHR0v.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CACgBf2I.css">
</head>
<body>
<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',
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: {
type: 'object',
additionalProperties: false,
@ -674,7 +674,7 @@ Other tools:
- 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_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_snapshot / mindmap_restore_snapshot — named checkpoints
- mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist