feat: glass cards with readable attribute lists

Replace raw JSON on card faces with key-value lists, simulate frosted glass on canvas, and use a list editor plus real backdrop blur for the selected overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 21:17:00 +08:00
parent 4c6e5964a8
commit 200c8bd450
13 changed files with 1090 additions and 286 deletions

View file

@ -5,6 +5,8 @@ import {
fillAccentWash,
paintAccentRail,
paintBadgeChip,
paintGlassGlow,
paintGlassHighlight,
paintNodeShadow,
} from "./canvas-chrome";
import {
@ -490,19 +492,57 @@ export function TopologyCanvas() {
if (isFlash && highlightStyle?.nodeGlow !== false) {
ctx.shadowColor = flashNodeColor;
ctx.shadowBlur = 14 / viewport.zoom;
} else if (cardView) {
// Accent bloom pass (low alpha), then glass body with drop shadow
ctx.save();
paintGlassGlow(ctx, fill, viewport.zoom, isSel);
ctx.globalAlpha = (inGhost ? 0.7 : 1) * (isSel ? 0.28 : 0.16);
ctx.fillStyle = fill;
drawNodeShape(
ctx,
"card",
x,
y,
boxW,
boxH,
dens ? 14 : 16,
);
ctx.fill();
ctx.restore();
if (inGhost) ctx.globalAlpha = 0.7;
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
} else {
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
}
ctx.fillStyle = theme.bgLayer1;
ctx.fillStyle = cardView ? theme.glassFill : theme.bgLayer1;
const drawPreset = cardView ? "card" : n.stylePreset;
const cornerR = cardView ? 14 : n.stylePreset === "title" ? boxH / 2 : 12;
const cornerR = cardView
? dens
? 14
: 16
: n.stylePreset === "title"
? boxH / 2
: 12;
drawNodeShape(ctx, drawPreset, x, y, boxW, boxH, cornerR);
ctx.fill();
clearShadow(ctx);
ctx.globalAlpha = inGhost ? 0.7 : 1;
fillAccentWash(
ctx,
fill,
cardView ? 0.1 : n.stylePreset === "title" ? 0.22 : 0.14,
cardView ? 0.06 : n.stylePreset === "title" ? 0.22 : 0.14,
);
if (cardView) {
paintGlassHighlight(
ctx,
x,
y,
boxW,
boxH,
cornerR,
theme.glassHighlight,
);
}
if (
!cardView &&
n.stylePreset !== "decision" &&
@ -516,26 +556,38 @@ export function TopologyCanvas() {
} else {
ctx.setLineDash([]);
}
ctx.strokeStyle = isForbidden
? theme.warn
: isDrop
? theme.brand
: !cardView && n.stylePreset === "risk"
? theme.warn
: isFlash
? flashNodeColor
: isSel || isHover
? theme.brand
: fill;
ctx.lineWidth =
(isDrop ||
isFlash ||
isSel ||
cardView ||
n.stylePreset === "title" ||
n.stylePreset === "decision"
? 2.4
: 1.35) / viewport.zoom;
// Card: thin glass border; soft accent only on hover/select (not thick colored frame)
if (cardView) {
ctx.strokeStyle = isForbidden
? theme.warn
: isDrop || isFlash
? theme.brand
: isSel || isHover
? fill
: theme.glassBorder;
ctx.lineWidth =
(isDrop || isFlash || isSel || isHover ? 1.6 : 1) / viewport.zoom;
} else {
ctx.strokeStyle = isForbidden
? theme.warn
: isDrop
? theme.brand
: n.stylePreset === "risk"
? theme.warn
: isFlash
? flashNodeColor
: isSel || isHover
? theme.brand
: fill;
ctx.lineWidth =
(isDrop ||
isFlash ||
isSel ||
n.stylePreset === "title" ||
n.stylePreset === "decision"
? 2.4
: 1.35) / viewport.zoom;
}
if (!cardView && n.stylePreset === "muted")
ctx.globalAlpha = inGhost ? 0.4 : 0.55;
clearShadow(ctx);
@ -558,18 +610,18 @@ export function TopologyCanvas() {
() => setImageTick((t) => t + 1),
);
} else {
// Match card header so overlay mount doesn't flash empty
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.fillRect(x, y, boxW, 36);
// Match glass header so overlay mount doesn't flash empty
ctx.fillStyle = theme.glassHeader;
ctx.fillRect(x, y, boxW, 40);
ctx.beginPath();
ctx.arc(x + 14, y + 18, 4.5, 0, Math.PI * 2);
ctx.arc(x + 16, y + 20, 4, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(13, 600);
ctx.font = canvasFont(14, 600);
ctx.textBaseline = "middle";
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 40, 1);
ctx.fillText(ph[0] ?? "?", x + 26, y + 18);
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 44, 1);
ctx.fillText(ph[0] ?? "?", x + 28, y + 20);
}
} else {
const padX = 14;

View file

@ -76,7 +76,13 @@ export function paintBadgeChip(
text: string,
x: number,
y: number,
theme: { labelSecondary: string; border: string; bgLayer1: string },
theme: {
labelSecondary: string;
border: string;
bgLayer1: string;
glassHeader?: string;
glassBorder?: string;
},
zoom: number,
): number {
const padX = 6;
@ -92,9 +98,9 @@ export function paintBadgeChip(
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.06)";
ctx.fillStyle = theme.glassHeader ?? "rgba(255,255,255,0.06)";
ctx.fill();
ctx.strokeStyle = theme.border;
ctx.strokeStyle = theme.glassBorder ?? theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
ctx.stroke();
ctx.fillStyle = theme.labelSecondary;
@ -102,3 +108,45 @@ export function paintBadgeChip(
ctx.fillText(text, x + padX, y + h / 2);
return w + 4;
}
/** Soft outer glow in accent hue for glass cards (shadow only — do not change fill alpha). */
export function paintGlassGlow(
ctx: CanvasRenderingContext2D,
accent: string,
zoom: number,
selected?: boolean,
) {
ctx.shadowColor = accent;
ctx.shadowBlur = (selected ? 20 : 12) / Math.max(0.5, zoom);
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
/** Inner top highlight strip for frosted-glass feel. */
export function paintGlassHighlight(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
radius: number,
highlight: string,
) {
ctx.save();
ctx.beginPath();
const rr = Math.min(radius, h / 2, w / 2);
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + Math.min(h * 0.45, 28));
ctx.lineTo(x, y + Math.min(h * 0.45, 28));
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
const g = ctx.createLinearGradient(x, y, x, y + 28);
g.addColorStop(0, highlight);
g.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
}

View file

@ -1,5 +1,15 @@
import type { MindNode, Op, StylePreset } from "@mind-map/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import {
canonicalDescriptionKey,
displayDescriptionKey,
} from "./description-display";
import {
commitField,
fieldEditorSeed,
@ -12,6 +22,40 @@ import "./node-shell.css";
type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
type KvRow = { id: string; key: string; value: string };
function rowsFromDescription(
description: Record<string, unknown> | null | undefined,
): KvRow[] {
if (!description) return [];
return Object.entries(description).map(([key, raw], i) => ({
id: `${key}-${i}`,
key: displayDescriptionKey(key),
value:
raw == null
? ""
: typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "boolean"
? String(raw)
: Array.isArray(raw)
? raw.map(String).join("、")
: JSON.stringify(raw),
}));
}
function descriptionFromRows(
rows: KvRow[],
): Record<string, unknown> | null {
const out: Record<string, unknown> = {};
for (const row of rows) {
const k = canonicalDescriptionKey(row.key);
if (!k) continue;
out[k] = row.value.trim();
}
return Object.keys(out).length ? out : null;
}
export function NodePropEditor({
node,
applyOps,
@ -39,8 +83,18 @@ export function NodePropEditor({
() => visiblePropRows(node, forced),
[node, forced],
);
const mainRows = useMemo(
() =>
rows.filter(
(r) => r.def.id !== "text" && r.def.id !== "description",
),
[rows],
);
const showDescriptionList =
!PROP_DESCRIPTION_EMPTY(node) || forced.has("description");
const addable = useMemo(
() => hiddenAddableFields(node, forced),
() =>
hiddenAddableFields(node, forced).filter((f) => f.id !== "description"),
[node, forced],
);
@ -57,9 +111,55 @@ export function NodePropEditor({
}
};
const commitDescription = (next: Record<string, unknown> | null) => {
const prev = node.description ?? null;
const same =
JSON.stringify(prev) === JSON.stringify(next);
if (same) return;
void applyOps(
[
{
type: "update_node_meta",
nodeId: node.id,
patch: { description: next },
},
],
"prop description",
);
if (!next) {
setForced((prev) => {
const n = new Set(prev);
n.delete("description");
return n;
});
}
};
const renderField = (def: PropFieldDef) => (
<FieldRow
key={def.id}
def={def}
node={node}
onCommit={(raw) => commit(def.id, raw)}
onClear={() => {
commit(def.id, "");
setForced((prev) => {
const next = new Set(prev);
next.delete(def.id);
return next;
});
}}
/>
);
return (
<div
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
style={
accent && variant === "overlay"
? ({ "--ns-brand": accent } as CSSProperties)
: undefined
}
>
<div
className="ns-editor__header"
@ -116,84 +216,90 @@ export function NodePropEditor({
</div>
) : null}
<div className="ns-row">
<span className="ns-row__label">强调色</span>
<div className="ns-row__ctrl">
<input
type="text"
placeholder="#e74c3c"
defaultValue={node.accentColor ?? ""}
key={`ac-${node.id}-${node.version}`}
onBlur={(e) => {
const v = e.target.value.trim();
const next = v || null;
if ((node.accentColor ?? null) === next) return;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: { accentColor: next as never },
},
]);
}}
/>
</div>
<span />
</div>
<div className="ns-row">
<span className="ns-row__label">图标</span>
<div className="ns-row__ctrl">
<select
value={node.icon ?? ""}
onChange={(e) => {
const v = e.target.value;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: {
icon: (v || null) as never,
},
},
]);
}}
>
<option value="">无</option>
<option value="server">🖥 服务器</option>
<option value="database">🗄 数据库</option>
<option value="cloud">☁ 云</option>
<option value="person">👤 人物</option>
<option value="folder">📁 文件夹</option>
<option value="doc">📄 文档</option>
<option value="link">🔗 链接</option>
<option value="warning">⚠ 警告</option>
<option value="check">✓ 完成</option>
<option value="star">★ 星标</option>
<option value="gear">⚙ 设置</option>
<option value="globe">🌐 全球</option>
</select>
</div>
<span />
</div>
{variant === "panel" ? (
<>
<div className="ns-row">
<span className="ns-row__label">强调色</span>
<div className="ns-row__ctrl">
<input
type="text"
placeholder="#e74c3c"
defaultValue={node.accentColor ?? ""}
key={`ac-${node.id}-${node.version}`}
onBlur={(e) => {
const v = e.target.value.trim();
const next = v || null;
if ((node.accentColor ?? null) === next) return;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: { accentColor: next as never },
},
]);
}}
/>
</div>
<span />
</div>
<div className="ns-row">
<span className="ns-row__label">图标</span>
<div className="ns-row__ctrl">
<select
value={node.icon ?? ""}
onChange={(e) => {
const v = e.target.value;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: {
icon: (v || null) as never,
},
},
]);
}}
>
<option value="">无</option>
<option value="server">🖥 服务器</option>
<option value="database">🗄 数据库</option>
<option value="cloud">☁ 云</option>
<option value="person">👤 人物</option>
<option value="folder">📁 文件夹</option>
<option value="doc">📄 文档</option>
<option value="link">🔗 链接</option>
<option value="warning">⚠ 警告</option>
<option value="check">✓ 完成</option>
<option value="star">★ 星标</option>
<option value="gear">⚙ 设置</option>
<option value="globe">🌐 全球</option>
</select>
</div>
<span />
</div>
</>
) : null}
{rows
.filter((r) => r.def.id !== "text")
.map(({ def }) => (
<FieldRow
key={def.id}
def={def}
node={node}
onCommit={(raw) => commit(def.id, raw)}
onClear={() => {
commit(def.id, "");
setForced((prev) => {
const next = new Set(prev);
next.delete(def.id);
return next;
});
}}
/>
))}
{mainRows.map(({ def }) => renderField(def))}
{showDescriptionList ? (
<DescriptionListEditor
nodeId={node.id}
version={node.version}
description={node.description}
onCommit={commitDescription}
/>
) : (
<button
type="button"
className="ns-kv__reveal"
onClick={() =>
setForced((prev) => new Set(prev).add("description"))
}
>
+ 添加属性列表
</button>
)}
</div>
{addable.length > 0 ? (
@ -237,6 +343,98 @@ export function NodePropEditor({
);
}
function PROP_DESCRIPTION_EMPTY(node: MindNode): boolean {
return !node.description || Object.keys(node.description).length === 0;
}
function DescriptionListEditor({
nodeId,
version,
description,
onCommit,
}: {
nodeId: string;
version: number;
description: Record<string, unknown> | null | undefined;
onCommit: (next: Record<string, unknown> | null) => void;
}) {
const [rows, setRows] = useState<KvRow[]>(() =>
rowsFromDescription(description),
);
const rowsRef = useRef(rows);
rowsRef.current = rows;
useEffect(() => {
setRows(rowsFromDescription(description));
}, [nodeId, version, description]);
const flush = (next: KvRow[]) => {
setRows(next);
rowsRef.current = next;
onCommit(descriptionFromRows(next));
};
return (
<div className="ns-kv" onPointerDown={(e) => e.stopPropagation()}>
<div className="ns-kv__title">属性</div>
<ul className="ns-kv__list">
{rows.map((row) => (
<li key={row.id} className="ns-kv__row">
<input
className="ns-kv__key"
value={row.key}
placeholder="名称"
aria-label="属性名"
onChange={(e) => {
const key = e.target.value;
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, key } : r)),
);
}}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
/>
<input
className="ns-kv__val"
value={row.value}
placeholder="内容"
aria-label="属性值"
onChange={(e) => {
const value = e.target.value;
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, value } : r)),
);
}}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
/>
<button
type="button"
className="ns-kv__del"
title="删除"
onClick={() =>
flush(rowsRef.current.filter((r) => r.id !== row.id))
}
>
×
</button>
</li>
))}
</ul>
<button
type="button"
className="ns-kv__add"
onClick={() => {
setRows((prev) => [
...prev,
{ id: `new-${Date.now()}`, key: "", value: "" },
]);
}}
>
+ 添加一项
</button>
</div>
);
}
function FieldRow({
def,
node,

View file

@ -0,0 +1,124 @@
/** Human-readable display helpers for MindNode.description (never show raw JSON on card face). */
export type DescChip = {
key: string;
label: string;
value: string;
};
const LABEL_MAP: Record<string, string> = {
role: "角色",
birth: "生卒",
born: "出生",
death: "卒年",
dynasty: "朝代",
era: "年代",
nickname: "别称",
alias: "别称",
title: "头衔",
name: "名称",
function: "职能",
summary: "概要",
type: "类型",
category: "分类",
status: "状态",
location: "地点",
place: "地点",
org: "组织",
organization: "组织",
party: "党派",
nation: "国家",
year: "年份",
date: "日期",
period: "时期",
event: "事件",
cause: "原因",
effect: "影响",
result: "结果",
note: "备注",
sla: "SLA",
};
const MAX_CHIPS = 6;
const MAX_VALUE_LEN = 36;
function truncate(s: string, max: number): string {
const t = s.trim();
if (t.length <= max) return t;
return `${t.slice(0, max - 1)}…`;
}
function formatValue(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;
}
if (typeof v === "number" || typeof v === "boolean") {
return truncate(String(v), MAX_VALUE_LEN);
}
if (Array.isArray(v)) {
const parts = v
.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);
}
if (typeof v === "object") {
try {
return truncate(JSON.stringify(v), MAX_VALUE_LEN);
} catch {
return null;
}
}
return null;
}
function labelForKey(key: string): string {
const lower = key.toLowerCase();
return LABEL_MAP[key] || LABEL_MAP[lower] || key;
}
/** Reverse map: Chinese label → canonical key (first match wins). */
const LABEL_TO_KEY: Record<string, string> = (() => {
const out: Record<string, string> = {};
for (const [k, label] of Object.entries(LABEL_MAP)) {
if (!out[label]) out[label] = k;
}
return out;
})();
export function canonicalDescriptionKey(input: string): string {
const t = input.trim();
if (!t) return t;
return LABEL_TO_KEY[t] || t;
}
export function displayDescriptionKey(key: string): string {
return labelForKey(key);
}
/** Flatten description object into labeled chips for card / editor face. */
export function descriptionChips(
description: Record<string, unknown> | null | undefined,
max = MAX_CHIPS,
): DescChip[] {
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 });
if (out.length >= max) break;
}
return out;
}
/** One-line preview for registry / tooltips (not JSON). */
export function descriptionPreviewLine(
description: Record<string, unknown> | null | undefined,
): string | null {
const chips = descriptionChips(description, 3);
if (!chips.length) return null;
return chips.map((c) => `${c.label} ${c.value}`).join(" · ");
}

View file

@ -1,18 +1,37 @@
import type { MindNode } from "@mind-map/shared";
import { canvasFont } from "../canvas-chrome";
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 { visiblePropRows } from "./registry";
import { descriptionChips } from "./description-display";
import {
CARD_CHIP_H,
CARD_GAP,
CARD_HEADER_H,
CARD_HERO_H,
CARD_PAD_X,
CARD_PAD_Y,
estimateCardSize,
} from "./sizing";
/** Read-only card chrome + stacked property rows (shell already filled by canvas). */
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url.slice(0, 28);
}
}
function formatDate(ms?: number): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
try {
return new Date(ms).toLocaleDateString();
} catch {
return null;
}
}
/** Glass card body: title → hero → note → meta chips → footer (no raw JSON). */
export function drawPropSheet(
ctx: CanvasRenderingContext2D,
node: MindNode,
@ -25,17 +44,18 @@ export function drawPropSheet(
onImage: () => void,
) {
const { w, h } = estimateCardSize(node, dens);
const rows = visiblePropRows(node);
const radius = dens ? 12 : 14;
const radius = dens ? 14 : 16;
const z = Math.max(0.5, zoom);
// Top accent hairline
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x, y, w, h, radius);
ctx.clip();
// Soft top accent hairline (identity without thick colored frame)
ctx.fillStyle = accent;
ctx.globalAlpha = 0.9;
ctx.fillRect(x, y, w, 3);
ctx.globalAlpha = 0.85;
ctx.fillRect(x, y, w, 2.5);
ctx.globalAlpha = 1;
// Low zoom: title only
@ -58,19 +78,26 @@ export function drawPropSheet(
return;
}
// Header band
ctx.fillStyle = "rgba(0,0,0,0.2)";
// Glass header band
ctx.fillStyle = theme.glassHeader;
ctx.fillRect(x, y, w, CARD_HEADER_H);
// Accent dot with soft halo
ctx.beginPath();
ctx.arc(x + 14, y + CARD_HEADER_H / 2, 4.5, 0, Math.PI * 2);
ctx.arc(x + 16, y + CARD_HEADER_H / 2, 7, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.globalAlpha = 0.22;
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(x + 16, y + CARD_HEADER_H / 2, 4, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.fill();
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 12 : 13, 600);
ctx.font = canvasFont(dens ? 13 : 14, 600);
ctx.textBaseline = "middle";
const titleMaxW = w - 28 - CARD_PAD_X;
const titleMaxW = w - 32 - CARD_PAD_X;
const titleLines = wrapTextLines(
ctx,
node.text || "未命名",
@ -78,17 +105,18 @@ export function drawPropSheet(
dens ? 1 : 2,
);
if (titleLines.length === 1) {
ctx.fillText(titleLines[0]!, x + 26, y + CARD_HEADER_H / 2);
ctx.fillText(titleLines[0]!, x + 28, y + CARD_HEADER_H / 2);
} else {
const lh = 13;
const lh = 14;
const ty = y + CARD_HEADER_H / 2 - lh / 2;
titleLines.forEach((line, i) => {
ctx.fillText(line, x + 26, ty + i * lh);
ctx.fillText(line, x + 28, ty + i * lh);
});
}
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
// Hairline under header
ctx.strokeStyle = theme.glassBorder;
ctx.lineWidth = 1 / z;
ctx.beginPath();
ctx.moveTo(x + CARD_PAD_X, y + CARD_HEADER_H);
ctx.lineTo(x + w - CARD_PAD_X, y + CARD_HEADER_H);
@ -97,66 +125,117 @@ export function drawPropSheet(
let cy = y + CARD_HEADER_H + CARD_PAD_Y;
const contentW = w - CARD_PAD_X * 2;
for (const { def, preview } of rows) {
if (def.id === "text") continue;
if (def.imageRow && preview) {
const ih = dens ? 52 : 64;
const img = getCachedImage(preview, onImage);
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 8);
ctx.clip();
if (img && img.naturalWidth > 0) {
// Contain: 等比例缩小完整入框,不裁切
const scale = Math.min(
contentW / img.naturalWidth,
ih / img.naturalHeight,
);
const dw = img.naturalWidth * scale;
const dh = img.naturalHeight * scale;
ctx.fillStyle = "rgba(0,0,0,0.22)";
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.drawImage(
img,
x + CARD_PAD_X + (contentW - dw) / 2,
cy + (ih - dh) / 2,
dw,
dh,
);
} else {
ctx.fillStyle = theme.border;
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(11, 500);
ctx.textBaseline = "middle";
ctx.fillText(
imageLoadFailed(preview) ? "配图失败" : "加载中…",
x + CARD_PAD_X + 10,
cy + ih / 2,
);
}
ctx.restore();
cy += ih + CARD_GAP;
continue;
// Hero image
const imageUrl = node.imageUrl?.trim();
if (imageUrl) {
const ih = dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable;
const img = getCachedImage(imageUrl, onImage);
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 10);
ctx.clip();
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,
);
const dw = img.naturalWidth * scale;
const dh = img.naturalHeight * scale;
ctx.drawImage(
img,
x + CARD_PAD_X + (contentW - dw) / 2,
cy + (ih - dh) / 2,
dw,
dh,
);
} else {
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(11, 500);
ctx.textBaseline = "middle";
ctx.fillText(
imageLoadFailed(imageUrl) ? "配图失败" : "加载中…",
x + CARD_PAD_X + 10,
cy + ih / 2,
);
}
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(dens ? 10 : 11, 500);
ctx.textBaseline = "top";
ctx.fillText(def.label, x + CARD_PAD_X, cy);
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 11 : 12, 400);
const text = preview ?? "—";
const maxLines = def.kind === "textarea" ? 3 : 2;
const lines = wrapTextLines(ctx, text, contentW, maxLines);
let ly = cy + (dens ? 14 : 15);
for (const line of lines) {
ctx.fillText(line, x + CARD_PAD_X, ly);
ly += dens ? 14 : 15;
}
cy = Math.max(cy + def.rowHeight, ly) + CARD_GAP;
ctx.restore();
cy += ih + CARD_GAP;
}
// Note summary
const note = node.note?.trim();
if (note) {
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 lh = dens ? 15 : 16;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i]!, x + CARD_PAD_X, cy + i * lh);
}
cy += lines.length * lh + CARD_GAP;
}
// Description as readable key-value list (never raw JSON)
const chips = descriptionChips(node.description);
if (chips.length) {
for (let i = 0; i < chips.length; i++) {
const chip = chips[i]!;
if (i > 0) {
ctx.strokeStyle = theme.glassBorder;
ctx.globalAlpha = 0.55;
ctx.lineWidth = 1 / z;
ctx.beginPath();
ctx.moveTo(x + CARD_PAD_X, cy - 2);
ctx.lineTo(x + w - CARD_PAD_X, cy - 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
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 + 6;
}
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}`);
}
if (node.links?.[0]) {
const l = node.links[0];
footerBits.push(l.title?.trim() || hostnameOf(l.url));
}
const due = formatDate(node.dueAt);
const start = formatDate(node.startAt);
if (start) footerBits.push(start);
if (due) footerBits.push(due);
if (node.pinned) footerBits.push("钉");
if (footerBits.length) {
let fx = x + CARD_PAD_X;
const fy = cy;
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;
}
}
ctx.restore();
}

View file

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

View file

@ -1,11 +1,15 @@
/* Node property system — card / inspector shell */
/* Node property system — frosted glass card / inspector shell */
.ns-editor {
--ns-bg: var(--dsw-alias-bg-layer-1, #1b1e28);
--ns-border: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
--ns-bg: color-mix(
in srgb,
var(--dsw-alias-bg-layer-1, #1b1e28) 72%,
transparent
);
--ns-border: rgba(255, 255, 255, 0.14);
--ns-label: var(--dsw-alias-label-secondary, #a5adb8);
--ns-text: var(--dsw-alias-label-primary, #f4f5f7);
--ns-brand: var(--dsw-alias-brand-primary, #5e6ad2);
--ns-input: rgba(255, 255, 255, 0.04);
--ns-input: rgba(255, 255, 255, 0.06);
--ns-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
display: flex;
@ -13,11 +17,13 @@
gap: 0;
background: var(--ns-bg);
border: 1px solid var(--ns-border);
border-radius: 14px;
border-radius: 16px;
overflow: visible;
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 10px 32px rgba(0, 0, 0, 0.38);
0 1px 0 rgba(255, 255, 255, 0.1) inset,
0 12px 40px rgba(0, 0, 0, 0.42);
color: var(--ns-text);
font-family: var(--ns-font);
font-size: 12.5px;
@ -33,25 +39,32 @@
background: transparent;
border: none;
border-radius: 0;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.ns-editor__header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
padding: 12px 14px;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.28) 0%,
rgba(0, 0, 0, 0.14) 100%
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-bottom: 1px solid var(--ns-border);
border-radius: 14px 14px 0 0;
border-radius: 16px 16px 0 0;
cursor: grab;
user-select: none;
overflow: hidden;
}
.ns-editor--panel .ns-editor__header {
border-radius: 0;
background: transparent;
}
.ns-editor__header:active {
cursor: grabbing;
}
@ -90,6 +103,205 @@
padding: 8px 10px 10px;
}
.ns-meta-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 4px 10px;
}
.ns-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
line-height: 1.3;
}
.ns-chip__k {
color: var(--ns-label);
font-weight: 500;
flex-shrink: 0;
}
.ns-chip__v {
color: var(--ns-text);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.ns-kv {
margin: 4px 2px 6px;
padding: 8px 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.ns-kv__title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ns-label);
margin: 0 2px 8px;
}
.ns-kv__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.ns-kv__row {
display: grid;
grid-template-columns: minmax(64px, 0.34fr) 1fr auto;
gap: 6px;
align-items: center;
padding: 6px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.ns-kv__row:first-child {
border-top: none;
padding-top: 0;
}
.ns-kv__key,
.ns-kv__val {
width: 100%;
min-width: 0;
border: 1px solid transparent;
background: var(--ns-input);
color: var(--ns-text);
font-family: inherit;
font-size: 12px;
border-radius: 8px;
padding: 6px 8px;
outline: none;
}
.ns-kv__key {
color: var(--ns-label);
font-weight: 500;
}
.ns-kv__key:focus,
.ns-kv__val:focus {
border-color: color-mix(in srgb, var(--ns-brand) 70%, transparent);
background: rgba(255, 255, 255, 0.055);
}
.ns-kv__del {
border: none;
background: transparent;
color: var(--ns-label);
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
opacity: 0.7;
}
.ns-kv__del:hover {
opacity: 1;
color: var(--ns-text);
background: rgba(255, 255, 255, 0.06);
}
.ns-kv__add,
.ns-kv__reveal {
width: 100%;
margin-top: 8px;
border: 1px dashed rgba(255, 255, 255, 0.14);
background: transparent;
color: var(--ns-label);
border-radius: 8px;
padding: 7px 10px;
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
}
.ns-kv__reveal {
margin: 6px 2px;
}
.ns-kv__add:hover,
.ns-kv__reveal:hover {
border-color: var(--ns-brand);
color: var(--ns-text);
}
.ns-advanced {
margin: 6px 2px 2px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.ns-advanced > summary {
list-style: none;
cursor: pointer;
user-select: none;
padding: 8px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ns-label);
}
.ns-advanced > summary::-webkit-details-marker {
display: none;
}
.ns-advanced > summary::before {
content: "▸";
display: inline-block;
margin-right: 6px;
transition: transform 0.12s ease;
}
.ns-advanced[open] > summary::before {
transform: rotate(90deg);
}
.ns-advanced[open] > summary {
color: var(--ns-text);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ns-advanced__body {
padding: 4px 6px 8px;
}
.ns-advanced__add {
width: 100%;
border: 1px dashed rgba(255, 255, 255, 0.12);
background: transparent;
color: var(--ns-label);
border-radius: 8px;
padding: 8px 10px;
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
}
.ns-advanced__add:hover {
border-color: var(--ns-brand);
color: var(--ns-text);
}
.ns-row {
display: grid;
grid-template-columns: 1fr;
@ -261,7 +473,13 @@
/* Open downward so it isn't clipped by siblings / host */
top: calc(100% + 6px);
bottom: auto;
background: var(--ns-bg);
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer-1, #1b1e28) 88%,
transparent
);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--ns-border);
border-radius: 10px;
padding: 4px;

View file

@ -1,4 +1,5 @@
import type { LinkRef, MindNode, Op } from "@mind-map/shared";
import { descriptionPreviewLine } from "./description-display";
import type { PropFieldDef, PropFieldId, VisiblePropRow } from "./types";
function hostnameOf(url: string): string {
@ -48,6 +49,7 @@ function linksToText(links: LinkRef[] | undefined): string {
.join("\n");
}
/** Order: title → image → note → meta → footer fields → advanced JSON. */
export const PROP_FIELDS: PropFieldDef[] = [
{
id: "text",
@ -61,64 +63,6 @@ export const PROP_FIELDS: PropFieldDef[] = [
{ type: "update_text", nodeId, text: raw.trim() || "未命名" },
],
},
{
id: "note",
label: "摘要",
kind: "textarea",
rowHeight: 18,
isEmpty: (n) => !n.note?.trim(),
preview: (n) => {
const t = n.note?.trim();
if (!t) return null;
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { note: raw.trim() || null },
},
],
},
{
id: "description",
label: "结构化描述 (JSON)",
kind: "textarea",
rowHeight: 36,
isEmpty: (n) => !n.description || Object.keys(n.description).length === 0,
preview: (n) => {
if (!n.description || !Object.keys(n.description).length) return null;
const t = JSON.stringify(n.description);
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return [
{
type: "update_node_meta",
nodeId,
patch: { description: null },
},
];
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("description must be a JSON object");
}
return [
{
type: "update_node_meta",
nodeId,
patch: { description: parsed as Record<string, unknown> },
},
];
} catch {
return [];
}
},
},
{
id: "imageUrl",
label: "配图",
@ -135,6 +79,25 @@ export const PROP_FIELDS: PropFieldDef[] = [
},
],
},
{
id: "note",
label: "摘要",
kind: "textarea",
rowHeight: 18,
isEmpty: (n) => !n.note?.trim(),
preview: (n) => {
const t = n.note?.trim();
if (!t) return null;
return t.length > 72 ? `${t.slice(0, 72)}…` : t;
},
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { note: raw.trim() || null },
},
],
},
{
id: "tags",
label: "标签",
@ -210,6 +173,42 @@ export const PROP_FIELDS: PropFieldDef[] = [
return [{ type: "set_pinned", nodeId, pinned: on }];
},
},
{
id: "description",
label: "属性",
kind: "textarea",
rowHeight: 36,
isEmpty: (n) => !n.description || Object.keys(n.description).length === 0,
preview: (n) => descriptionPreviewLine(n.description),
commit: (nodeId, raw) => {
// Prefer structured list editor; raw string path kept for tooling.
const trimmed = raw.trim();
if (!trimmed) {
return [
{
type: "update_node_meta",
nodeId,
patch: { description: null },
},
];
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("description must be a JSON object");
}
return [
{
type: "update_node_meta",
nodeId,
patch: { description: parsed as Record<string, unknown> },
},
];
} catch {
return [];
}
},
},
];
export const PROP_FIELD_BY_ID = Object.fromEntries(

View file

@ -1,29 +1,48 @@
import type { MindNode } from "@mind-map/shared";
import { visiblePropRows } from "./registry";
import { descriptionChips } from "./description-display";
export const CARD_WIDTH = { comfortable: 288, compact: 248 } as const;
export const CARD_HEADER_H = 36;
export const CARD_HEADER_H = 40;
export const CARD_PAD_Y = 12;
export const CARD_PAD_X = 12;
export const CARD_PAD_X = 14;
export const CARD_GAP = 10;
export const CARD_HERO_H = { comfortable: 108, compact: 90 } as const;
export const CARD_CHIP_H = 18;
export const CARD_FOOTER_H = 24;
/** Layout / hit-test height for a card node. */
/** 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(),
_forced: ReadonlySet<string> = new Set(),
): { w: number; h: number } {
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
const rows = visiblePropRows(node, forced);
let body = 0;
for (const { def } of rows) {
if (def.id === "text") continue;
// Stacked label+value needs a bit more than legacy side-by-side rows.
const rh =
def.imageRow ? (dens ? 52 : 64) + 4 : Math.max(def.rowHeight + 12, dens ? 28 : 32);
body += rh + CARD_GAP;
if (node.imageUrl?.trim()) {
body += (dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable) + CARD_GAP;
}
if (node.note?.trim()) {
body += (dens ? 40 : 48) + CARD_GAP;
}
const chips = descriptionChips(node.description);
if (chips.length) {
body += chips.length * CARD_CHIP_H + Math.max(0, chips.length - 1) * 4 + CARD_GAP;
}
const hasFooter =
Boolean(node.tags?.length) ||
Boolean(node.links?.length) ||
node.startAt != null ||
node.dueAt != null ||
Boolean(node.pinned);
if (hasFooter) {
body += CARD_FOOTER_H + CARD_GAP;
}
if (body > 0) body -= CARD_GAP;
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 12);
return { w, h: Math.max(h, dens ? 84 : 100) };
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 8);
return { w, h: Math.max(h, dens ? 88 : 104) };
}

View file

@ -28,6 +28,8 @@ export type PropFieldDef = {
always?: boolean;
/** Extra height when this row is an image preview. */
imageRow?: boolean;
/** @deprecated Prefer dedicated list UI for description; kept for compat. */
advanced?: boolean;
rowHeight: number;
isEmpty: (node: MindNode) => boolean;
preview: (node: MindNode) => string | null;

View file

@ -9,9 +9,14 @@ export type SystemTheme = {
bubble: string;
warn: string;
error: string;
/** Semi-transparent card fill (canvas glass simulation). */
glassFill: string;
glassBorder: string;
glassHighlight: string;
glassHeader: string;
};
const FALLBACK: SystemTheme = {
const FALLBACK_DARK: SystemTheme = {
bgBase: "#101014",
bgLayer1: "#1b1e28",
labelPrimary: "#f4f5f7",
@ -21,8 +26,41 @@ const FALLBACK: SystemTheme = {
bubble: "rgba(37,42,58,0.9)",
warn: "#f59e5b",
error: "#ef4444",
glassFill: "rgba(28, 32, 44, 0.72)",
glassBorder: "rgba(255, 255, 255, 0.14)",
glassHighlight: "rgba(255, 255, 255, 0.1)",
glassHeader: "rgba(255, 255, 255, 0.045)",
};
const FALLBACK_LIGHT: SystemTheme = {
bgBase: "#f4f5f7",
bgLayer1: "#ffffff",
labelPrimary: "#1f2329",
labelSecondary: "#646a73",
brand: "#5e6ad2",
border: "rgba(0,0,0,0.08)",
bubble: "rgba(255,255,255,0.92)",
warn: "#d97706",
error: "#dc2626",
glassFill: "rgba(255, 255, 255, 0.78)",
glassBorder: "rgba(0, 0, 0, 0.1)",
glassHighlight: "rgba(255, 255, 255, 0.65)",
glassHeader: "rgba(0, 0, 0, 0.03)",
};
function detectColorScheme(): "light" | "dark" {
try {
const ds = document.documentElement.dataset.colorScheme;
if (ds === "light" || ds === "dark") return ds;
if (matchMedia("(prefers-color-scheme: light)").matches) return "light";
} catch {
/* ignore */
}
return "dark";
}
const FALLBACK: SystemTheme = FALLBACK_DARK;
const VAR_MAP: { key: keyof SystemTheme; css: string }[] = [
{ key: "bgBase", css: "--dsw-alias-bg-base" },
{ key: "bgLayer1", css: "--dsw-alias-bg-layer-1" },
@ -58,11 +96,18 @@ export function applyThemeFromQuery(search = window.location.search): void {
}
export function readSystemTheme(): SystemTheme {
const out = { ...FALLBACK };
const scheme = detectColorScheme();
const base = scheme === "light" ? FALLBACK_LIGHT : FALLBACK_DARK;
const out = { ...base };
for (const { key, css } of VAR_MAP) {
const v = cssVar(css);
if (v) out[key] = v;
}
// Glass tokens stay scheme-aware (CSS vars are usually opaque solids).
out.glassFill = base.glassFill;
out.glassBorder = base.glassBorder;
out.glassHighlight = base.glassHighlight;
out.glassHeader = base.glassHeader;
return out;
}