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;