diff --git a/apps/web/src/TopologyCanvas.tsx b/apps/web/src/TopologyCanvas.tsx index 8862d89..fed9af9 100644 --- a/apps/web/src/TopologyCanvas.tsx +++ b/apps/web/src/TopologyCanvas.tsx @@ -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; diff --git a/apps/web/src/canvas-chrome.ts b/apps/web/src/canvas-chrome.ts index eaf9dbd..c138040 100644 --- a/apps/web/src/canvas-chrome.ts +++ b/apps/web/src/canvas-chrome.ts @@ -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(); +} diff --git a/apps/web/src/node-props/NodePropEditor.tsx b/apps/web/src/node-props/NodePropEditor.tsx index 464c8d4..7e32e0c 100644 --- a/apps/web/src/node-props/NodePropEditor.tsx +++ b/apps/web/src/node-props/NodePropEditor.tsx @@ -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; +type KvRow = { id: string; key: string; value: string }; + +function rowsFromDescription( + description: Record | 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 | null { + const out: Record = {}; + 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 | 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) => ( + commit(def.id, raw)} + onClear={() => { + commit(def.id, ""); + setForced((prev) => { + const next = new Set(prev); + next.delete(def.id); + return next; + }); + }} + /> + ); + return (
) : null} -
- 强调色 -
- { - 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 }, - }, - ]); - }} - /> -
- -
-
- 图标 -
- -
- -
+ {variant === "panel" ? ( + <> +
+ 强调色 +
+ { + 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 }, + }, + ]); + }} + /> +
+ +
+
+ 图标 +
+ +
+ +
+ + ) : null} - {rows - .filter((r) => r.def.id !== "text") - .map(({ def }) => ( - 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 ? ( + + ) : ( + + )}
{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 | null | undefined; + onCommit: (next: Record | null) => void; +}) { + const [rows, setRows] = useState(() => + 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 ( +
e.stopPropagation()}> +
属性
+
    + {rows.map((row) => ( +
  • + { + const key = e.target.value; + setRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, key } : r)), + ); + }} + onBlur={() => onCommit(descriptionFromRows(rowsRef.current))} + /> + { + const value = e.target.value; + setRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, value } : r)), + ); + }} + onBlur={() => onCommit(descriptionFromRows(rowsRef.current))} + /> + +
  • + ))} +
+ +
+ ); +} + function FieldRow({ def, node, diff --git a/apps/web/src/node-props/description-display.ts b/apps/web/src/node-props/description-display.ts new file mode 100644 index 0000000..3318681 --- /dev/null +++ b/apps/web/src/node-props/description-display.ts @@ -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 = { + 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 = (() => { + const out: Record = {}; + 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 | 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 | null | undefined, +): string | null { + const chips = descriptionChips(description, 3); + if (!chips.length) return null; + return chips.map((c) => `${c.label} ${c.value}`).join(" · "); +} diff --git a/apps/web/src/node-props/draw-sheet.ts b/apps/web/src/node-props/draw-sheet.ts index 54f6887..c75828e 100644 --- a/apps/web/src/node-props/draw-sheet.ts +++ b/apps/web/src/node-props/draw-sheet.ts @@ -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(); } diff --git a/apps/web/src/node-props/index.ts b/apps/web/src/node-props/index.ts index 14c5d10..02956d5 100644 --- a/apps/web/src/node-props/index.ts +++ b/apps/web/src/node-props/index.ts @@ -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"; diff --git a/apps/web/src/node-props/node-shell.css b/apps/web/src/node-props/node-shell.css index dad0d00..3929322 100644 --- a/apps/web/src/node-props/node-shell.css +++ b/apps/web/src/node-props/node-shell.css @@ -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; diff --git a/apps/web/src/node-props/registry.ts b/apps/web/src/node-props/registry.ts index 07a9cb8..10c70a0 100644 --- a/apps/web/src/node-props/registry.ts +++ b/apps/web/src/node-props/registry.ts @@ -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 }, - }, - ]; - } 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 }, + }, + ]; + } catch { + return []; + } + }, + }, ]; export const PROP_FIELD_BY_ID = Object.fromEntries( diff --git a/apps/web/src/node-props/sizing.ts b/apps/web/src/node-props/sizing.ts index 28d0012..7f055f6 100644 --- a/apps/web/src/node-props/sizing.ts +++ b/apps/web/src/node-props/sizing.ts @@ -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 = new Set(), + _forced: ReadonlySet = 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) }; } diff --git a/apps/web/src/node-props/types.ts b/apps/web/src/node-props/types.ts index b166575..010f9e0 100644 --- a/apps/web/src/node-props/types.ts +++ b/apps/web/src/node-props/types.ts @@ -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; diff --git a/apps/web/src/system-theme.ts b/apps/web/src/system-theme.ts index d0bb345..d67033d 100644 --- a/apps/web/src/system-theme.ts +++ b/apps/web/src/system-theme.ts @@ -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; } diff --git a/packages/layout-engine/src/index.test.ts b/packages/layout-engine/src/index.test.ts index a2fef47..ba047d3 100644 --- a/packages/layout-engine/src/index.test.ts +++ b/packages/layout-engine/src/index.test.ts @@ -208,6 +208,7 @@ describe("layout-engine", () => { tags: [], links: [], note: undefined, + description: undefined, dueAt: undefined, startAt: undefined, pinned: false, @@ -222,6 +223,7 @@ describe("layout-engine", () => { tags: ["a"], links: [{ url: "https://ex.com" }], note: "hello", + description: { role: "创始人", birth: "1879" }, dueAt: 1, startAt: undefined, pinned: true, diff --git a/packages/layout-engine/src/index.ts b/packages/layout-engine/src/index.ts index 002e5b2..9b5a612 100644 --- a/packages/layout-engine/src/index.ts +++ b/packages/layout-engine/src/index.ts @@ -46,6 +46,7 @@ export function nodeLayoutSize( | "tags" | "links" | "note" + | "description" | "dueAt" | "startAt" | "pinned" @@ -67,20 +68,36 @@ export function nodeLayoutSize( const preset: StylePreset = node.stylePreset ?? "default"; const asCard = Boolean(opts?.asCard) || preset === "card"; if (asCard) { - // Keep in sync with apps/web/src/node-props/sizing.ts - const w = dens.nodeW >= 200 ? 288 : 248; + // Keep in sync with apps/web/src/node-props/sizing.ts (glass card hierarchy) + const cardW = dens.nodeW >= 200 ? 288 : 248; const gap = 10; + const headerH = 40; + const padY = 12; let body = 0; - if (node.note) body += 32 + gap; - if (node.imageUrl) body += 68 + gap; - if (node.tags?.length) body += 32 + gap; - if (node.links?.length) body += 32 + gap; - if (node.startAt != null) body += 32 + gap; - if (node.dueAt != null) body += 32 + gap; - if (node.pinned) body += 32 + gap; + if (node.imageUrl) body += (dens.nodeW >= 200 ? 108 : 90) + gap; + if (node.note) body += (dens.nodeW >= 200 ? 48 : 40) + gap; + const descCount = node.description + ? Math.min( + 6, + Object.values(node.description).filter( + (v) => v != null && String(v).trim() !== "", + ).length, + ) + : 0; + if (descCount) body += descCount * 18 + Math.max(0, descCount - 1) * 4 + gap; + const hasFooter = + Boolean(node.tags?.length) || + Boolean(node.links?.length) || + node.startAt != null || + node.dueAt != null || + Boolean(node.pinned); + if (hasFooter) body += 24 + gap; if (body > 0) body -= gap; - const h = 36 + 24 + Math.max(body, 12); - return { w, h: Math.max(h, dens.nodeW >= 200 ? 100 : 84) }; + const cardH = headerH + padY * 2 + Math.max(body, 8); + return { + w: cardW, + h: Math.max(cardH, dens.nodeW >= 200 ? 104 : 88), + }; } if (preset === "title") { w *= 1.2;