mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 04:30:46 +08:00
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:
parent
4c6e5964a8
commit
200c8bd450
13 changed files with 1090 additions and 286 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
124
apps/web/src/node-props/description-display.ts
Normal file
124
apps/web/src/node-props/description-display.ts
Normal 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(" · ");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue