mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 03:20:49 +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,
|
fillAccentWash,
|
||||||
paintAccentRail,
|
paintAccentRail,
|
||||||
paintBadgeChip,
|
paintBadgeChip,
|
||||||
|
paintGlassGlow,
|
||||||
|
paintGlassHighlight,
|
||||||
paintNodeShadow,
|
paintNodeShadow,
|
||||||
} from "./canvas-chrome";
|
} from "./canvas-chrome";
|
||||||
import {
|
import {
|
||||||
|
|
@ -490,19 +492,57 @@ export function TopologyCanvas() {
|
||||||
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
||||||
ctx.shadowColor = flashNodeColor;
|
ctx.shadowColor = flashNodeColor;
|
||||||
ctx.shadowBlur = 14 / viewport.zoom;
|
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 {
|
} else {
|
||||||
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
|
paintNodeShadow(ctx, viewport.zoom, { selected: isSel });
|
||||||
}
|
}
|
||||||
ctx.fillStyle = theme.bgLayer1;
|
ctx.fillStyle = cardView ? theme.glassFill : theme.bgLayer1;
|
||||||
const drawPreset = cardView ? "card" : n.stylePreset;
|
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);
|
drawNodeShape(ctx, drawPreset, x, y, boxW, boxH, cornerR);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
clearShadow(ctx);
|
||||||
|
ctx.globalAlpha = inGhost ? 0.7 : 1;
|
||||||
fillAccentWash(
|
fillAccentWash(
|
||||||
ctx,
|
ctx,
|
||||||
fill,
|
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 (
|
if (
|
||||||
!cardView &&
|
!cardView &&
|
||||||
n.stylePreset !== "decision" &&
|
n.stylePreset !== "decision" &&
|
||||||
|
|
@ -516,26 +556,38 @@ export function TopologyCanvas() {
|
||||||
} else {
|
} else {
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
ctx.strokeStyle = isForbidden
|
// Card: thin glass border; soft accent only on hover/select (not thick colored frame)
|
||||||
? theme.warn
|
if (cardView) {
|
||||||
: isDrop
|
ctx.strokeStyle = isForbidden
|
||||||
? theme.brand
|
? theme.warn
|
||||||
: !cardView && n.stylePreset === "risk"
|
: isDrop || isFlash
|
||||||
? theme.warn
|
? theme.brand
|
||||||
: isFlash
|
: isSel || isHover
|
||||||
? flashNodeColor
|
? fill
|
||||||
: isSel || isHover
|
: theme.glassBorder;
|
||||||
? theme.brand
|
ctx.lineWidth =
|
||||||
: fill;
|
(isDrop || isFlash || isSel || isHover ? 1.6 : 1) / viewport.zoom;
|
||||||
ctx.lineWidth =
|
} else {
|
||||||
(isDrop ||
|
ctx.strokeStyle = isForbidden
|
||||||
isFlash ||
|
? theme.warn
|
||||||
isSel ||
|
: isDrop
|
||||||
cardView ||
|
? theme.brand
|
||||||
n.stylePreset === "title" ||
|
: n.stylePreset === "risk"
|
||||||
n.stylePreset === "decision"
|
? theme.warn
|
||||||
? 2.4
|
: isFlash
|
||||||
: 1.35) / viewport.zoom;
|
? 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")
|
if (!cardView && n.stylePreset === "muted")
|
||||||
ctx.globalAlpha = inGhost ? 0.4 : 0.55;
|
ctx.globalAlpha = inGhost ? 0.4 : 0.55;
|
||||||
clearShadow(ctx);
|
clearShadow(ctx);
|
||||||
|
|
@ -558,18 +610,18 @@ export function TopologyCanvas() {
|
||||||
() => setImageTick((t) => t + 1),
|
() => setImageTick((t) => t + 1),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Match card header so overlay mount doesn't flash empty
|
// Match glass header so overlay mount doesn't flash empty
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
ctx.fillStyle = theme.glassHeader;
|
||||||
ctx.fillRect(x, y, boxW, 36);
|
ctx.fillRect(x, y, boxW, 40);
|
||||||
ctx.beginPath();
|
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.fillStyle = fill;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = theme.labelPrimary;
|
ctx.fillStyle = theme.labelPrimary;
|
||||||
ctx.font = canvasFont(13, 600);
|
ctx.font = canvasFont(14, 600);
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 40, 1);
|
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 44, 1);
|
||||||
ctx.fillText(ph[0] ?? "?", x + 26, y + 18);
|
ctx.fillText(ph[0] ?? "?", x + 28, y + 20);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const padX = 14;
|
const padX = 14;
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,13 @@ export function paintBadgeChip(
|
||||||
text: string,
|
text: string,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
theme: { labelSecondary: string; border: string; bgLayer1: string },
|
theme: {
|
||||||
|
labelSecondary: string;
|
||||||
|
border: string;
|
||||||
|
bgLayer1: string;
|
||||||
|
glassHeader?: string;
|
||||||
|
glassBorder?: string;
|
||||||
|
},
|
||||||
zoom: number,
|
zoom: number,
|
||||||
): number {
|
): number {
|
||||||
const padX = 6;
|
const padX = 6;
|
||||||
|
|
@ -92,9 +98,9 @@ export function paintBadgeChip(
|
||||||
ctx.arcTo(x, y + h, x, y, r);
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
ctx.arcTo(x, y, x + w, y, r);
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.06)";
|
ctx.fillStyle = theme.glassHeader ?? "rgba(255,255,255,0.06)";
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = theme.border;
|
ctx.strokeStyle = theme.glassBorder ?? theme.border;
|
||||||
ctx.lineWidth = 1 / Math.max(0.5, zoom);
|
ctx.lineWidth = 1 / Math.max(0.5, zoom);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = theme.labelSecondary;
|
ctx.fillStyle = theme.labelSecondary;
|
||||||
|
|
@ -102,3 +108,45 @@ export function paintBadgeChip(
|
||||||
ctx.fillText(text, x + padX, y + h / 2);
|
ctx.fillText(text, x + padX, y + h / 2);
|
||||||
return w + 4;
|
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 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 {
|
import {
|
||||||
commitField,
|
commitField,
|
||||||
fieldEditorSeed,
|
fieldEditorSeed,
|
||||||
|
|
@ -12,6 +22,40 @@ import "./node-shell.css";
|
||||||
|
|
||||||
type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
|
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({
|
export function NodePropEditor({
|
||||||
node,
|
node,
|
||||||
applyOps,
|
applyOps,
|
||||||
|
|
@ -39,8 +83,18 @@ export function NodePropEditor({
|
||||||
() => visiblePropRows(node, forced),
|
() => visiblePropRows(node, forced),
|
||||||
[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(
|
const addable = useMemo(
|
||||||
() => hiddenAddableFields(node, forced),
|
() =>
|
||||||
|
hiddenAddableFields(node, forced).filter((f) => f.id !== "description"),
|
||||||
[node, forced],
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
|
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
|
||||||
|
style={
|
||||||
|
accent && variant === "overlay"
|
||||||
|
? ({ "--ns-brand": accent } as CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="ns-editor__header"
|
className="ns-editor__header"
|
||||||
|
|
@ -116,84 +216,90 @@ export function NodePropEditor({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="ns-row">
|
{variant === "panel" ? (
|
||||||
<span className="ns-row__label">强调色</span>
|
<>
|
||||||
<div className="ns-row__ctrl">
|
<div className="ns-row">
|
||||||
<input
|
<span className="ns-row__label">强调色</span>
|
||||||
type="text"
|
<div className="ns-row__ctrl">
|
||||||
placeholder="#e74c3c"
|
<input
|
||||||
defaultValue={node.accentColor ?? ""}
|
type="text"
|
||||||
key={`ac-${node.id}-${node.version}`}
|
placeholder="#e74c3c"
|
||||||
onBlur={(e) => {
|
defaultValue={node.accentColor ?? ""}
|
||||||
const v = e.target.value.trim();
|
key={`ac-${node.id}-${node.version}`}
|
||||||
const next = v || null;
|
onBlur={(e) => {
|
||||||
if ((node.accentColor ?? null) === next) return;
|
const v = e.target.value.trim();
|
||||||
void applyOps([
|
const next = v || null;
|
||||||
{
|
if ((node.accentColor ?? null) === next) return;
|
||||||
type: "update_node_meta",
|
void applyOps([
|
||||||
nodeId: node.id,
|
{
|
||||||
patch: { accentColor: next as never },
|
type: "update_node_meta",
|
||||||
},
|
nodeId: node.id,
|
||||||
]);
|
patch: { accentColor: next as never },
|
||||||
}}
|
},
|
||||||
/>
|
]);
|
||||||
</div>
|
}}
|
||||||
<span />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ns-row">
|
<span />
|
||||||
<span className="ns-row__label">图标</span>
|
</div>
|
||||||
<div className="ns-row__ctrl">
|
<div className="ns-row">
|
||||||
<select
|
<span className="ns-row__label">图标</span>
|
||||||
value={node.icon ?? ""}
|
<div className="ns-row__ctrl">
|
||||||
onChange={(e) => {
|
<select
|
||||||
const v = e.target.value;
|
value={node.icon ?? ""}
|
||||||
void applyOps([
|
onChange={(e) => {
|
||||||
{
|
const v = e.target.value;
|
||||||
type: "update_node_meta",
|
void applyOps([
|
||||||
nodeId: node.id,
|
{
|
||||||
patch: {
|
type: "update_node_meta",
|
||||||
icon: (v || null) as never,
|
nodeId: node.id,
|
||||||
},
|
patch: {
|
||||||
},
|
icon: (v || null) as never,
|
||||||
]);
|
},
|
||||||
}}
|
},
|
||||||
>
|
]);
|
||||||
<option value="">无</option>
|
}}
|
||||||
<option value="server">🖥 服务器</option>
|
>
|
||||||
<option value="database">🗄 数据库</option>
|
<option value="">无</option>
|
||||||
<option value="cloud">☁ 云</option>
|
<option value="server">🖥 服务器</option>
|
||||||
<option value="person">👤 人物</option>
|
<option value="database">🗄 数据库</option>
|
||||||
<option value="folder">📁 文件夹</option>
|
<option value="cloud">☁ 云</option>
|
||||||
<option value="doc">📄 文档</option>
|
<option value="person">👤 人物</option>
|
||||||
<option value="link">🔗 链接</option>
|
<option value="folder">📁 文件夹</option>
|
||||||
<option value="warning">⚠ 警告</option>
|
<option value="doc">📄 文档</option>
|
||||||
<option value="check">✓ 完成</option>
|
<option value="link">🔗 链接</option>
|
||||||
<option value="star">★ 星标</option>
|
<option value="warning">⚠ 警告</option>
|
||||||
<option value="gear">⚙ 设置</option>
|
<option value="check">✓ 完成</option>
|
||||||
<option value="globe">🌐 全球</option>
|
<option value="star">★ 星标</option>
|
||||||
</select>
|
<option value="gear">⚙ 设置</option>
|
||||||
</div>
|
<option value="globe">🌐 全球</option>
|
||||||
<span />
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{rows
|
{mainRows.map(({ def }) => renderField(def))}
|
||||||
.filter((r) => r.def.id !== "text")
|
|
||||||
.map(({ def }) => (
|
{showDescriptionList ? (
|
||||||
<FieldRow
|
<DescriptionListEditor
|
||||||
key={def.id}
|
nodeId={node.id}
|
||||||
def={def}
|
version={node.version}
|
||||||
node={node}
|
description={node.description}
|
||||||
onCommit={(raw) => commit(def.id, raw)}
|
onCommit={commitDescription}
|
||||||
onClear={() => {
|
/>
|
||||||
commit(def.id, "");
|
) : (
|
||||||
setForced((prev) => {
|
<button
|
||||||
const next = new Set(prev);
|
type="button"
|
||||||
next.delete(def.id);
|
className="ns-kv__reveal"
|
||||||
return next;
|
onClick={() =>
|
||||||
});
|
setForced((prev) => new Set(prev).add("description"))
|
||||||
}}
|
}
|
||||||
/>
|
>
|
||||||
))}
|
+ 添加属性列表
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addable.length > 0 ? (
|
{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({
|
function FieldRow({
|
||||||
def,
|
def,
|
||||||
node,
|
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 type { MindNode } from "@mind-map/shared";
|
||||||
import { canvasFont } from "../canvas-chrome";
|
import { canvasFont, paintBadgeChip } from "../canvas-chrome";
|
||||||
import { getCachedImage, imageLoadFailed } from "../image-cache";
|
import { getCachedImage, imageLoadFailed } from "../image-cache";
|
||||||
import type { SystemTheme } from "../system-theme";
|
import type { SystemTheme } from "../system-theme";
|
||||||
import { roundRectPath, wrapTextLines } from "../node-geometry";
|
import { roundRectPath, wrapTextLines } from "../node-geometry";
|
||||||
import { visiblePropRows } from "./registry";
|
import { descriptionChips } from "./description-display";
|
||||||
import {
|
import {
|
||||||
|
CARD_CHIP_H,
|
||||||
CARD_GAP,
|
CARD_GAP,
|
||||||
CARD_HEADER_H,
|
CARD_HEADER_H,
|
||||||
|
CARD_HERO_H,
|
||||||
CARD_PAD_X,
|
CARD_PAD_X,
|
||||||
CARD_PAD_Y,
|
CARD_PAD_Y,
|
||||||
estimateCardSize,
|
estimateCardSize,
|
||||||
} from "./sizing";
|
} 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(
|
export function drawPropSheet(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
node: MindNode,
|
node: MindNode,
|
||||||
|
|
@ -25,17 +44,18 @@ export function drawPropSheet(
|
||||||
onImage: () => void,
|
onImage: () => void,
|
||||||
) {
|
) {
|
||||||
const { w, h } = estimateCardSize(node, dens);
|
const { w, h } = estimateCardSize(node, dens);
|
||||||
const rows = visiblePropRows(node);
|
const radius = dens ? 14 : 16;
|
||||||
const radius = dens ? 12 : 14;
|
const z = Math.max(0.5, zoom);
|
||||||
|
|
||||||
// Top accent hairline
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
roundRectPath(ctx, x, y, w, h, radius);
|
roundRectPath(ctx, x, y, w, h, radius);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
|
// Soft top accent hairline (identity without thick colored frame)
|
||||||
ctx.fillStyle = accent;
|
ctx.fillStyle = accent;
|
||||||
ctx.globalAlpha = 0.9;
|
ctx.globalAlpha = 0.85;
|
||||||
ctx.fillRect(x, y, w, 3);
|
ctx.fillRect(x, y, w, 2.5);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
// Low zoom: title only
|
// Low zoom: title only
|
||||||
|
|
@ -58,19 +78,26 @@ export function drawPropSheet(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header band
|
// Glass header band
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
ctx.fillStyle = theme.glassHeader;
|
||||||
ctx.fillRect(x, y, w, CARD_HEADER_H);
|
ctx.fillRect(x, y, w, CARD_HEADER_H);
|
||||||
|
|
||||||
|
// Accent dot with soft halo
|
||||||
ctx.beginPath();
|
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.fillStyle = accent;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.fillStyle = theme.labelPrimary;
|
ctx.fillStyle = theme.labelPrimary;
|
||||||
ctx.font = canvasFont(dens ? 12 : 13, 600);
|
ctx.font = canvasFont(dens ? 13 : 14, 600);
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
const titleMaxW = w - 28 - CARD_PAD_X;
|
const titleMaxW = w - 32 - CARD_PAD_X;
|
||||||
const titleLines = wrapTextLines(
|
const titleLines = wrapTextLines(
|
||||||
ctx,
|
ctx,
|
||||||
node.text || "未命名",
|
node.text || "未命名",
|
||||||
|
|
@ -78,17 +105,18 @@ export function drawPropSheet(
|
||||||
dens ? 1 : 2,
|
dens ? 1 : 2,
|
||||||
);
|
);
|
||||||
if (titleLines.length === 1) {
|
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 {
|
} else {
|
||||||
const lh = 13;
|
const lh = 14;
|
||||||
const ty = y + CARD_HEADER_H / 2 - lh / 2;
|
const ty = y + CARD_HEADER_H / 2 - lh / 2;
|
||||||
titleLines.forEach((line, i) => {
|
titleLines.forEach((line, i) => {
|
||||||
ctx.fillText(line, x + 26, ty + i * lh);
|
ctx.fillText(line, x + 28, ty + i * lh);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = theme.border;
|
// Hairline under header
|
||||||
ctx.lineWidth = 1 / Math.max(0.5, zoom);
|
ctx.strokeStyle = theme.glassBorder;
|
||||||
|
ctx.lineWidth = 1 / z;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x + CARD_PAD_X, y + CARD_HEADER_H);
|
ctx.moveTo(x + CARD_PAD_X, y + CARD_HEADER_H);
|
||||||
ctx.lineTo(x + w - 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;
|
let cy = y + CARD_HEADER_H + CARD_PAD_Y;
|
||||||
const contentW = w - CARD_PAD_X * 2;
|
const contentW = w - CARD_PAD_X * 2;
|
||||||
|
|
||||||
for (const { def, preview } of rows) {
|
// Hero image
|
||||||
if (def.id === "text") continue;
|
const imageUrl = node.imageUrl?.trim();
|
||||||
|
if (imageUrl) {
|
||||||
if (def.imageRow && preview) {
|
const ih = dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable;
|
||||||
const ih = dens ? 52 : 64;
|
const img = getCachedImage(imageUrl, onImage);
|
||||||
const img = getCachedImage(preview, onImage);
|
ctx.save();
|
||||||
ctx.save();
|
ctx.beginPath();
|
||||||
ctx.beginPath();
|
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 10);
|
||||||
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 8);
|
ctx.clip();
|
||||||
ctx.clip();
|
ctx.fillStyle = theme.glassHeader;
|
||||||
if (img && img.naturalWidth > 0) {
|
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
|
||||||
// Contain: 等比例缩小完整入框,不裁切
|
if (img && img.naturalWidth > 0) {
|
||||||
const scale = Math.min(
|
// Cover: fill frame, crop edges lightly for magazine feel
|
||||||
contentW / img.naturalWidth,
|
const scale = Math.max(
|
||||||
ih / img.naturalHeight,
|
contentW / img.naturalWidth,
|
||||||
);
|
ih / img.naturalHeight,
|
||||||
const dw = img.naturalWidth * scale;
|
);
|
||||||
const dh = img.naturalHeight * scale;
|
const dw = img.naturalWidth * scale;
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.22)";
|
const dh = img.naturalHeight * scale;
|
||||||
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
|
ctx.drawImage(
|
||||||
ctx.drawImage(
|
img,
|
||||||
img,
|
x + CARD_PAD_X + (contentW - dw) / 2,
|
||||||
x + CARD_PAD_X + (contentW - dw) / 2,
|
cy + (ih - dh) / 2,
|
||||||
cy + (ih - dh) / 2,
|
dw,
|
||||||
dw,
|
dh,
|
||||||
dh,
|
);
|
||||||
);
|
} else {
|
||||||
} else {
|
ctx.fillStyle = theme.labelSecondary;
|
||||||
ctx.fillStyle = theme.border;
|
ctx.font = canvasFont(11, 500);
|
||||||
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillStyle = theme.labelSecondary;
|
ctx.fillText(
|
||||||
ctx.font = canvasFont(11, 500);
|
imageLoadFailed(imageUrl) ? "配图失败" : "加载中…",
|
||||||
ctx.textBaseline = "middle";
|
x + CARD_PAD_X + 10,
|
||||||
ctx.fillText(
|
cy + ih / 2,
|
||||||
imageLoadFailed(preview) ? "配图失败" : "加载中…",
|
);
|
||||||
x + CARD_PAD_X + 10,
|
|
||||||
cy + ih / 2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
cy += ih + CARD_GAP;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
ctx.restore();
|
||||||
ctx.fillStyle = theme.labelSecondary;
|
cy += ih + CARD_GAP;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry";
|
export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry";
|
||||||
export { estimateCardSize, CARD_WIDTH } from "./sizing";
|
export { estimateCardSize, CARD_WIDTH } from "./sizing";
|
||||||
export { drawPropSheet } from "./draw-sheet";
|
export { drawPropSheet } from "./draw-sheet";
|
||||||
|
export { descriptionChips, descriptionPreviewLine } from "./description-display";
|
||||||
export { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
|
export { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
|
||||||
export type { PropFieldId, PropFieldDef } from "./types";
|
export type { PropFieldId, PropFieldDef } from "./types";
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
/* Node property system — card / inspector shell */
|
/* Node property system — frosted glass card / inspector shell */
|
||||||
.ns-editor {
|
.ns-editor {
|
||||||
--ns-bg: var(--dsw-alias-bg-layer-1, #1b1e28);
|
--ns-bg: color-mix(
|
||||||
--ns-border: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
|
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-label: var(--dsw-alias-label-secondary, #a5adb8);
|
||||||
--ns-text: var(--dsw-alias-label-primary, #f4f5f7);
|
--ns-text: var(--dsw-alias-label-primary, #f4f5f7);
|
||||||
--ns-brand: var(--dsw-alias-brand-primary, #5e6ad2);
|
--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",
|
--ns-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||||
"Noto Sans SC", system-ui, sans-serif;
|
"Noto Sans SC", system-ui, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -13,11 +17,13 @@
|
||||||
gap: 0;
|
gap: 0;
|
||||||
background: var(--ns-bg);
|
background: var(--ns-bg);
|
||||||
border: 1px solid var(--ns-border);
|
border: 1px solid var(--ns-border);
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
0 1px 0 rgba(255, 255, 255, 0.1) inset,
|
||||||
0 10px 32px rgba(0, 0, 0, 0.38);
|
0 12px 40px rgba(0, 0, 0, 0.42);
|
||||||
color: var(--ns-text);
|
color: var(--ns-text);
|
||||||
font-family: var(--ns-font);
|
font-family: var(--ns-font);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|
@ -33,25 +39,32 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ns-editor__header {
|
.ns-editor__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(0, 0, 0, 0.28) 0%,
|
rgba(255, 255, 255, 0.08) 0%,
|
||||||
rgba(0, 0, 0, 0.14) 100%
|
rgba(255, 255, 255, 0.02) 100%
|
||||||
);
|
);
|
||||||
border-bottom: 1px solid var(--ns-border);
|
border-bottom: 1px solid var(--ns-border);
|
||||||
border-radius: 14px 14px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ns-editor--panel .ns-editor__header {
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.ns-editor__header:active {
|
.ns-editor__header:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +103,205 @@
|
||||||
padding: 8px 10px 10px;
|
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 {
|
.ns-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -261,7 +473,13 @@
|
||||||
/* Open downward so it isn't clipped by siblings / host */
|
/* Open downward so it isn't clipped by siblings / host */
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
bottom: auto;
|
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: 1px solid var(--ns-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { LinkRef, MindNode, Op } from "@mind-map/shared";
|
import type { LinkRef, MindNode, Op } from "@mind-map/shared";
|
||||||
|
import { descriptionPreviewLine } from "./description-display";
|
||||||
import type { PropFieldDef, PropFieldId, VisiblePropRow } from "./types";
|
import type { PropFieldDef, PropFieldId, VisiblePropRow } from "./types";
|
||||||
|
|
||||||
function hostnameOf(url: string): string {
|
function hostnameOf(url: string): string {
|
||||||
|
|
@ -48,6 +49,7 @@ function linksToText(links: LinkRef[] | undefined): string {
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Order: title → image → note → meta → footer fields → advanced JSON. */
|
||||||
export const PROP_FIELDS: PropFieldDef[] = [
|
export const PROP_FIELDS: PropFieldDef[] = [
|
||||||
{
|
{
|
||||||
id: "text",
|
id: "text",
|
||||||
|
|
@ -61,64 +63,6 @@ export const PROP_FIELDS: PropFieldDef[] = [
|
||||||
{ type: "update_text", nodeId, text: raw.trim() || "未命名" },
|
{ 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",
|
id: "imageUrl",
|
||||||
label: "配图",
|
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",
|
id: "tags",
|
||||||
label: "标签",
|
label: "标签",
|
||||||
|
|
@ -210,6 +173,42 @@ export const PROP_FIELDS: PropFieldDef[] = [
|
||||||
return [{ type: "set_pinned", nodeId, pinned: on }];
|
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(
|
export const PROP_FIELD_BY_ID = Object.fromEntries(
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,48 @@
|
||||||
import type { MindNode } from "@mind-map/shared";
|
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_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_Y = 12;
|
||||||
export const CARD_PAD_X = 12;
|
export const CARD_PAD_X = 14;
|
||||||
export const CARD_GAP = 10;
|
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(
|
export function estimateCardSize(
|
||||||
node: MindNode,
|
node: MindNode,
|
||||||
dens: boolean,
|
dens: boolean,
|
||||||
forced: ReadonlySet<string> = new Set(),
|
_forced: ReadonlySet<string> = new Set(),
|
||||||
): { w: number; h: number } {
|
): { w: number; h: number } {
|
||||||
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
|
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
|
||||||
const rows = visiblePropRows(node, forced);
|
|
||||||
let body = 0;
|
let body = 0;
|
||||||
for (const { def } of rows) {
|
|
||||||
if (def.id === "text") continue;
|
if (node.imageUrl?.trim()) {
|
||||||
// Stacked label+value needs a bit more than legacy side-by-side rows.
|
body += (dens ? CARD_HERO_H.compact : CARD_HERO_H.comfortable) + CARD_GAP;
|
||||||
const rh =
|
|
||||||
def.imageRow ? (dens ? 52 : 64) + 4 : Math.max(def.rowHeight + 12, dens ? 28 : 32);
|
|
||||||
body += rh + 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;
|
if (body > 0) body -= CARD_GAP;
|
||||||
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 12);
|
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 8);
|
||||||
return { w, h: Math.max(h, dens ? 84 : 100) };
|
return { w, h: Math.max(h, dens ? 88 : 104) };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export type PropFieldDef = {
|
||||||
always?: boolean;
|
always?: boolean;
|
||||||
/** Extra height when this row is an image preview. */
|
/** Extra height when this row is an image preview. */
|
||||||
imageRow?: boolean;
|
imageRow?: boolean;
|
||||||
|
/** @deprecated Prefer dedicated list UI for description; kept for compat. */
|
||||||
|
advanced?: boolean;
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
isEmpty: (node: MindNode) => boolean;
|
isEmpty: (node: MindNode) => boolean;
|
||||||
preview: (node: MindNode) => string | null;
|
preview: (node: MindNode) => string | null;
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,14 @@ export type SystemTheme = {
|
||||||
bubble: string;
|
bubble: string;
|
||||||
warn: string;
|
warn: string;
|
||||||
error: 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",
|
bgBase: "#101014",
|
||||||
bgLayer1: "#1b1e28",
|
bgLayer1: "#1b1e28",
|
||||||
labelPrimary: "#f4f5f7",
|
labelPrimary: "#f4f5f7",
|
||||||
|
|
@ -21,8 +26,41 @@ const FALLBACK: SystemTheme = {
|
||||||
bubble: "rgba(37,42,58,0.9)",
|
bubble: "rgba(37,42,58,0.9)",
|
||||||
warn: "#f59e5b",
|
warn: "#f59e5b",
|
||||||
error: "#ef4444",
|
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 }[] = [
|
const VAR_MAP: { key: keyof SystemTheme; css: string }[] = [
|
||||||
{ key: "bgBase", css: "--dsw-alias-bg-base" },
|
{ key: "bgBase", css: "--dsw-alias-bg-base" },
|
||||||
{ key: "bgLayer1", css: "--dsw-alias-bg-layer-1" },
|
{ key: "bgLayer1", css: "--dsw-alias-bg-layer-1" },
|
||||||
|
|
@ -58,11 +96,18 @@ export function applyThemeFromQuery(search = window.location.search): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readSystemTheme(): SystemTheme {
|
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) {
|
for (const { key, css } of VAR_MAP) {
|
||||||
const v = cssVar(css);
|
const v = cssVar(css);
|
||||||
if (v) out[key] = v;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ describe("layout-engine", () => {
|
||||||
tags: [],
|
tags: [],
|
||||||
links: [],
|
links: [],
|
||||||
note: undefined,
|
note: undefined,
|
||||||
|
description: undefined,
|
||||||
dueAt: undefined,
|
dueAt: undefined,
|
||||||
startAt: undefined,
|
startAt: undefined,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
|
@ -222,6 +223,7 @@ describe("layout-engine", () => {
|
||||||
tags: ["a"],
|
tags: ["a"],
|
||||||
links: [{ url: "https://ex.com" }],
|
links: [{ url: "https://ex.com" }],
|
||||||
note: "hello",
|
note: "hello",
|
||||||
|
description: { role: "创始人", birth: "1879" },
|
||||||
dueAt: 1,
|
dueAt: 1,
|
||||||
startAt: undefined,
|
startAt: undefined,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function nodeLayoutSize(
|
||||||
| "tags"
|
| "tags"
|
||||||
| "links"
|
| "links"
|
||||||
| "note"
|
| "note"
|
||||||
|
| "description"
|
||||||
| "dueAt"
|
| "dueAt"
|
||||||
| "startAt"
|
| "startAt"
|
||||||
| "pinned"
|
| "pinned"
|
||||||
|
|
@ -67,20 +68,36 @@ export function nodeLayoutSize(
|
||||||
const preset: StylePreset = node.stylePreset ?? "default";
|
const preset: StylePreset = node.stylePreset ?? "default";
|
||||||
const asCard = Boolean(opts?.asCard) || preset === "card";
|
const asCard = Boolean(opts?.asCard) || preset === "card";
|
||||||
if (asCard) {
|
if (asCard) {
|
||||||
// Keep in sync with apps/web/src/node-props/sizing.ts
|
// Keep in sync with apps/web/src/node-props/sizing.ts (glass card hierarchy)
|
||||||
const w = dens.nodeW >= 200 ? 288 : 248;
|
const cardW = dens.nodeW >= 200 ? 288 : 248;
|
||||||
const gap = 10;
|
const gap = 10;
|
||||||
|
const headerH = 40;
|
||||||
|
const padY = 12;
|
||||||
let body = 0;
|
let body = 0;
|
||||||
if (node.note) body += 32 + gap;
|
if (node.imageUrl) body += (dens.nodeW >= 200 ? 108 : 90) + gap;
|
||||||
if (node.imageUrl) body += 68 + gap;
|
if (node.note) body += (dens.nodeW >= 200 ? 48 : 40) + gap;
|
||||||
if (node.tags?.length) body += 32 + gap;
|
const descCount = node.description
|
||||||
if (node.links?.length) body += 32 + gap;
|
? Math.min(
|
||||||
if (node.startAt != null) body += 32 + gap;
|
6,
|
||||||
if (node.dueAt != null) body += 32 + gap;
|
Object.values(node.description).filter(
|
||||||
if (node.pinned) body += 32 + gap;
|
(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;
|
if (body > 0) body -= gap;
|
||||||
const h = 36 + 24 + Math.max(body, 12);
|
const cardH = headerH + padY * 2 + Math.max(body, 8);
|
||||||
return { w, h: Math.max(h, dens.nodeW >= 200 ? 100 : 84) };
|
return {
|
||||||
|
w: cardW,
|
||||||
|
h: Math.max(cardH, dens.nodeW >= 200 ? 104 : 88),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (preset === "title") {
|
if (preset === "title") {
|
||||||
w *= 1.2;
|
w *= 1.2;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue