mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 00:43:20 +08:00
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:
parent
23ff27122f
commit
4c9bd5b86f
22 changed files with 978 additions and 315 deletions
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ function escapeXml(s: string): string {
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
format: format === "png" ? "svg" : "svg",
|
format: "svg",
|
||||||
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: "SVG can be saved as .svg or opened in browser / Office.",
|
||||||
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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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"',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
20
apps/web/src/PropTooltip.tsx
Normal file
20
apps/web/src/PropTooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
58
apps/web/src/export-canvas.ts
Normal file
58
apps/web/src/export-canvas.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
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) {
|
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 =
|
||||||
|
|
|
||||||
|
|
@ -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
51
packages/dsh-plugin/bundle/web/assets/index-B_0PHR0v.js
Normal file
51
packages/dsh-plugin/bundle/web/assets/index-B_0PHR0v.js
Normal file
File diff suppressed because one or more lines are too long
1
packages/dsh-plugin/bundle/web/assets/index-CACgBf2I.css
Normal file
1
packages/dsh-plugin/bundle/web/assets/index-CACgBf2I.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
packages/dsh-plugin/bundle/web/topo/ne-router.png
Normal file
BIN
packages/dsh-plugin/bundle/web/topo/ne-router.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue