fix: enlarge prop editor and support pinned property tips

Make the double-click editor wider with auto-growing textareas, clamp hover tips inside the viewport, and allow click-to-pin / right-click-to-unpin for presentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 11:02:43 +08:00
parent 4c9bd5b86f
commit 993fcaa2fd
6 changed files with 273 additions and 67 deletions

View file

@ -1,19 +1,68 @@
/** Floating property detail tip (card view hover). */ import { useLayoutEffect, useRef, useState } from "react";
/** Floating property detail tip (card view hover / pinned for present). */
export function PropTooltip({ export function PropTooltip({
label, label,
value, value,
x, x,
y, y,
pinned,
hostW,
hostH,
onUnpin,
}: { }: {
label: string; label: string;
value: string; value: string;
x: number; x: number;
y: number; y: number;
pinned?: boolean;
hostW: number;
hostH: number;
onUnpin?: () => void;
}) { }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ x, y });
useLayoutEffect(() => {
const el = ref.current;
if (!el) {
setPos({ x, y });
return;
}
const pad = 10;
const tw = el.offsetWidth;
const th = el.offsetHeight;
const maxX = Math.max(pad, hostW - pad - tw);
const maxY = Math.max(pad, hostH - pad - th);
setPos({
x: Math.min(maxX, Math.max(pad, x)),
y: Math.min(maxY, Math.max(pad, y)),
});
}, [x, y, label, value, hostW, hostH, pinned]);
return ( return (
<div className="prop-tooltip" style={{ left: x, top: y }} role="tooltip"> <div
<div className="prop-tooltip__label">{label}</div> ref={ref}
className={`prop-tooltip${pinned ? " prop-tooltip--pinned" : ""}`}
style={{ left: pos.x, top: pos.y }}
role="tooltip"
onContextMenu={(e) => {
if (!pinned) return;
e.preventDefault();
e.stopPropagation();
onUnpin?.();
}}
onPointerDown={(e) => {
if (pinned) e.stopPropagation();
}}
>
<div className="prop-tooltip__label">
{label}
{pinned ? (
<span className="prop-tooltip__pin">钉住 · 右键释放</span>
) : null}
</div>
<div className="prop-tooltip__value">{value}</div> <div className="prop-tooltip__value">{value}</div>
</div> </div>
); );

View file

@ -144,7 +144,12 @@ export function TopologyCanvas() {
value: string; value: string;
x: number; x: number;
y: number; y: number;
pinned: boolean;
nodeId: string;
fieldKey: string;
} | null>(null); } | null>(null);
const propTipRef = useRef(propTip);
propTipRef.current = propTip;
const [linkPreview, setLinkPreview] = useState<{ const [linkPreview, setLinkPreview] = useState<{
fromId: string; fromId: string;
x: number; x: number;
@ -283,7 +288,60 @@ export function TopologyCanvas() {
} }
return null; return null;
}, },
[nodes, posOf, dens], [nodes, posOf, boxOf],
);
/** Resolve card property under pointer (hover tip / pin for讲解). */
const hitCardField = useCallback(
(
clientX: number,
clientY: number,
world: { x: number; y: number },
): {
nodeId: string;
field: CardChipHit;
tipX: number;
tipY: number;
} | null => {
if (!cardView || viewport.zoom < 0.35) return null;
const hit = hitTest(world.x, world.y);
if (!hit || propEditId === hit) return null;
const n = nodes.find((x) => x.id === hit);
const p = n ? posOf(hit) : null;
if (!n || !p) return null;
const { w: bw, h: bh } = boxOf(n);
const localX = world.x - (p.x - bw / 2);
const localY = world.y - (p.y - bh / 2);
const fields = cardFieldHits(n, dens, expandedCardId === n.id);
const field = fields.find(
(f) =>
localX >= f.x &&
localX <= f.x + f.w &&
localY >= f.y &&
localY <= f.y + f.h,
);
if (!field) return null;
const host = hostRef.current?.getBoundingClientRect();
const tipX = host
? clientX - host.left + 14
: hostSize.w / 2 + viewport.x + world.x * viewport.zoom + 14;
const tipY = host
? clientY - host.top + 14
: hostSize.h / 2 + viewport.y + world.y * viewport.zoom + 14;
return { nodeId: hit, field, tipX, tipY };
},
[
cardView,
viewport,
hitTest,
propEditId,
nodes,
posOf,
boxOf,
dens,
expandedCardId,
hostSize,
],
); );
const worldBounds = useMemo(() => { const worldBounds = useMemo(() => {
@ -1246,49 +1304,22 @@ export function TopologyCanvas() {
} }
} }
// Idle hover: property tip in card view, node hover otherwise // Idle hover: property tip in card view (skip while a tip is pinned)
if (dragRef.current.mode === "none" && !ghost) { if (dragRef.current.mode === "none" && !ghost) {
const hit = hitTest(world.x, world.y); const hit = hitTest(world.x, world.y);
setHoverId(hit); setHoverId(hit);
if (cardView && hit && viewport.zoom >= 0.35 && propEditId !== hit) { if (propTipRef.current?.pinned) return;
const n = nodes.find((x) => x.id === hit); const found = hitCardField(e.clientX, e.clientY, world);
const p = n ? posOf(hit) : null; if (found) {
if (n && p) { setPropTip({
const { w: bw, h: bh } = boxOf(n); label: found.field.label,
const localX = world.x - (p.x - bw / 2); value: found.field.fullValue,
const localY = world.y - (p.y - bh / 2); x: found.tipX,
const fields = cardFieldHits( y: found.tipY,
n, pinned: false,
dens, nodeId: found.nodeId,
expandedCardId === n.id, fieldKey: found.field.key,
); });
const field = fields.find(
(f: CardChipHit) =>
localX >= f.x &&
localX <= f.x + f.w &&
localY >= f.y &&
localY <= f.y + f.h,
);
if (field) {
const host = hostRef.current?.getBoundingClientRect();
const tipX = host
? e.clientX - host.left + 14
: hostSize.w / 2 + viewport.x + world.x * viewport.zoom + 14;
const tipY = host
? e.clientY - host.top + 14
: hostSize.h / 2 + viewport.y + world.y * viewport.zoom + 14;
setPropTip({
label: field.label,
value: field.fullValue,
x: tipX,
y: tipY,
});
} else {
setPropTip(null);
}
} else {
setPropTip(null);
}
} else { } else {
setPropTip(null); setPropTip(null);
} }
@ -1373,13 +1404,30 @@ export function TopologyCanvas() {
} else if (mode === "node" && fromId && !moved) { } else if (mode === "node" && fromId && !moved) {
setSelection([fromId]); setSelection([fromId]);
if (cardView) { if (cardView) {
setExpandedCardId((prev) => (prev === fromId ? null : fromId)); const found = hitCardField(e.clientX, e.clientY, world);
if (propEditId && propEditId !== fromId) setPropEditId(null); if (found) {
// Click property → pin tip for present /讲解
setExpandedCardId(fromId);
if (propEditId && propEditId !== fromId) setPropEditId(null);
setPropTip({
label: found.field.label,
value: found.field.fullValue,
x: found.tipX,
y: found.tipY,
pinned: true,
nodeId: found.nodeId,
fieldKey: found.field.key,
});
} else {
setExpandedCardId((prev) => (prev === fromId ? null : fromId));
if (propEditId && propEditId !== fromId) setPropEditId(null);
}
} }
} else if (mode === "pan" && !moved) { } else if (mode === "pan" && !moved) {
setExpandedCardId(null); setExpandedCardId(null);
setPropEditId(null); setPropEditId(null);
setPropTip(null); // Keep pinned tip for讲解; only hover tip clears on blank click
setPropTip((t) => (t?.pinned ? t : null));
} }
dragRef.current.mode = "none"; dragRef.current.mode = "none";
@ -1392,7 +1440,14 @@ export function TopologyCanvas() {
setDropForbidden(false); setDropForbidden(false);
setDragCursor(null); setDragCursor(null);
}} }}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => {
e.preventDefault();
// Right-click releases a pinned property tip
if (propTipRef.current?.pinned) {
setPropTip(null);
return;
}
}}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (locked) return; if (locked) return;
const world = toWorld(e.clientX, e.clientY); const world = toWorld(e.clientX, e.clientY);
@ -1783,6 +1838,10 @@ export function TopologyCanvas() {
value={propTip.value} value={propTip.value}
x={propTip.x} x={propTip.x}
y={propTip.y} y={propTip.y}
pinned={propTip.pinned}
hostW={hostSize.w}
hostH={hostSize.h}
onUnpin={() => setPropTip(null)}
/> />
) : null} ) : null}

View file

@ -77,7 +77,7 @@ const zh: CanvasUi = {
card: "卡片", card: "卡片",
topology: "拓扑", topology: "拓扑",
bubbleTitle: "气泡视图:精简标题", bubbleTitle: "气泡视图:精简标题",
cardTitle: "卡片视图:单击展开全文 · 悬停属性看详情 · 双击编辑", cardTitle: "卡片:单击属性钉住浮层 · 右键释放 · 双击编辑",
topologyTitle: "拓扑视图:路由器图标 + 链路", topologyTitle: "拓扑视图:路由器图标 + 链路",
unpin: "解钉", unpin: "解钉",
unpinTitle: "取消钉住:让选中节点重新参与自动布局", unpinTitle: "取消钉住:让选中节点重新参与自动布局",
@ -127,7 +127,7 @@ const en: CanvasUi = {
card: "Card", card: "Card",
topology: "Topology", topology: "Topology",
bubbleTitle: "Bubble view: title only", bubbleTitle: "Bubble view: title only",
cardTitle: "Card view: click expand · hover props · double-click edit", cardTitle: "Card: click prop to pin tip · right-click unpin · double-click edit",
topologyTitle: "Topology view: router icons + links", topologyTitle: "Topology view: router icons + links",
unpin: "Unpin", unpin: "Unpin",
unpinTitle: "Unpin selected node for auto layout", unpinTitle: "Unpin selected node for auto layout",

View file

@ -24,6 +24,15 @@ type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
type KvRow = { id: string; key: string; value: string }; type KvRow = { id: string; key: string; value: string };
const OVERLAY_EDITOR_W = 440;
function autoResizeTextarea(el: HTMLTextAreaElement | null) {
if (!el) return;
el.style.height = "auto";
const next = Math.min(320, Math.max(el.scrollHeight, 96));
el.style.height = `${next}px`;
}
function rowsFromDescription( function rowsFromDescription(
description: Record<string, unknown> | null | undefined, description: Record<string, unknown> | null | undefined,
): KvRow[] { ): KvRow[] {
@ -160,6 +169,12 @@ export function NodePropEditor({
? ({ "--ns-brand": accent } as CSSProperties) ? ({ "--ns-brand": accent } as CSSProperties)
: undefined : undefined
} }
onPointerDown={(e) => {
if (variant === "overlay") e.stopPropagation();
}}
onWheel={(e) => {
if (variant === "overlay") e.stopPropagation();
}}
> >
<div <div
className="ns-editor__header" className="ns-editor__header"
@ -394,18 +409,24 @@ function DescriptionListEditor({
}} }}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))} onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
/> />
<input <textarea
className="ns-kv__val" className="ns-kv__val"
value={row.value} value={row.value}
placeholder="内容" placeholder="内容"
aria-label="属性值" aria-label="属性值"
rows={Math.min(10, Math.max(3, Math.ceil(row.value.length / 36)))}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setRows((prev) => setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, value } : r)), prev.map((r) => (r.id === row.id ? { ...r, value } : r)),
); );
autoResizeTextarea(e.target);
}} }}
onFocus={(e) => autoResizeTextarea(e.target)}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))} onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
ref={(el) => {
if (el) autoResizeTextarea(el);
}}
/> />
<button <button
type="button" type="button"
@ -525,10 +546,22 @@ function FieldRow({
{def.kind === "textarea" || def.kind === "links" ? ( {def.kind === "textarea" || def.kind === "links" ? (
<textarea <textarea
key={`${def.id}-${node.id}-${node.version}`} key={`${def.id}-${node.id}-${node.version}`}
className={
def.id === "note" || seed.length > 80 ? "ns-textarea--lg" : undefined
}
defaultValue={seed} defaultValue={seed}
rows={def.kind === "links" ? 3 : 2} rows={
def.kind === "links"
? Math.min(8, Math.max(3, seed.split("\n").length + 1))
: Math.min(12, Math.max(5, Math.ceil(seed.length / 40)))
}
placeholder={def.kind === "links" ? "标题|url 每行一条" : ""} placeholder={def.kind === "links" ? "标题|url 每行一条" : ""}
onFocus={(e) => autoResizeTextarea(e.target)}
onInput={(e) => autoResizeTextarea(e.currentTarget)}
onBlur={(e) => onCommit(e.target.value)} onBlur={(e) => onCommit(e.target.value)}
ref={(el) => {
if (el) autoResizeTextarea(el);
}}
/> />
) : def.kind === "datetime" ? ( ) : def.kind === "datetime" ? (
<input <input
@ -575,9 +608,9 @@ export function NodePropOverlay({
applyOps, applyOps,
screenX, screenX,
screenY, screenY,
zoom, zoom: _zoom,
accent, accent,
width, width: _width,
showStylePreset, showStylePreset,
}: { }: {
node: MindNode; node: MindNode;
@ -589,14 +622,16 @@ export function NodePropOverlay({
width: number; width: number;
showStylePreset?: boolean; showStylePreset?: boolean;
}) { }) {
// Keep editor at screen size (ignore canvas zoom / card width) so long text is editable.
const panelW = OVERLAY_EDITOR_W;
return ( return (
<div <div
className="ns-overlay" className="ns-overlay ns-overlay--roomy"
style={{ style={{
left: screenX, left: screenX,
top: screenY, top: screenY,
width, width: panelW,
transform: `translate(-50%, -50%) scale(${zoom})`, transform: "translate(-50%, -50%)",
}} }}
> >
<NodePropEditor <NodePropEditor

View file

@ -18,7 +18,7 @@
background: var(--ns-bg); background: var(--ns-bg);
border: 1px solid var(--ns-border); border: 1px solid var(--ns-border);
border-radius: 16px; border-radius: 16px;
overflow: visible; overflow: hidden;
backdrop-filter: blur(18px) saturate(1.15); backdrop-filter: blur(18px) saturate(1.15);
-webkit-backdrop-filter: blur(18px) saturate(1.15); -webkit-backdrop-filter: blur(18px) saturate(1.15);
box-shadow: box-shadow:
@ -28,17 +28,21 @@
font-family: var(--ns-font); font-family: var(--ns-font);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;
min-width: 248px; min-width: 360px;
max-width: 320px; width: 100%;
max-width: 480px;
max-height: min(72vh, 640px);
} }
.ns-editor--panel { .ns-editor--panel {
box-shadow: none; box-shadow: none;
max-width: none; max-width: none;
max-height: none;
width: 100%; width: 100%;
background: transparent; background: transparent;
border: none; border: none;
border-radius: 0; border-radius: 0;
overflow: visible;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
} }
@ -97,6 +101,10 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding: 8px 10px 10px; padding: 8px 10px 10px;
overflow-x: hidden;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
} }
.ns-meta-chips { .ns-meta-chips {
@ -169,9 +177,9 @@
.ns-kv__row { .ns-kv__row {
display: grid; display: grid;
grid-template-columns: minmax(64px, 0.34fr) 1fr auto; grid-template-columns: minmax(72px, 0.28fr) 1fr auto;
gap: 6px; gap: 6px;
align-items: center; align-items: start;
padding: 2px 0; padding: 2px 0;
border-top: none; border-top: none;
} }
@ -189,12 +197,20 @@
background: var(--ns-input); background: var(--ns-input);
color: var(--ns-text); color: var(--ns-text);
font-family: inherit; font-family: inherit;
font-size: 12px; font-size: 12.5px;
line-height: 1.45;
border-radius: 8px; border-radius: 8px;
padding: 6px 8px; padding: 8px 10px;
outline: none; outline: none;
} }
.ns-kv__val {
resize: vertical;
min-height: 72px;
max-height: 240px;
field-sizing: content;
}
.ns-kv__key { .ns-kv__key {
color: var(--ns-label); color: var(--ns-label);
font-weight: 500; font-weight: 500;
@ -214,6 +230,8 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
margin-top: 6px;
align-self: start;
} }
.ns-kv__del:hover { .ns-kv__del:hover {
@ -345,14 +363,25 @@
color: var(--ns-text); color: var(--ns-text);
font-family: inherit; font-family: inherit;
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45;
border-radius: 8px; border-radius: 8px;
padding: 7px 10px; padding: 8px 10px;
outline: none; outline: none;
resize: vertical; resize: vertical;
transition: border-color 0.12s ease, background 0.12s ease; transition: border-color 0.12s ease, background 0.12s ease;
color-scheme: dark; color-scheme: dark;
} }
.ns-row__ctrl textarea {
min-height: 96px;
max-height: 320px;
field-sizing: content;
}
.ns-row__ctrl textarea.ns-textarea--lg {
min-height: 140px;
}
.ns-row__ctrl select option { .ns-row__ctrl select option {
color: #f4f5f7; color: #f4f5f7;
background: #1b1e28; background: #1b1e28;
@ -518,8 +547,13 @@
transform-origin: center center; transform-origin: center center;
} }
.ns-overlay--roomy {
/* Editor stays screen-readable; do not shrink with canvas zoom. */
width: min(480px, calc(100vw - 48px));
}
.ns-editor { .ns-editor {
pointer-events: none; pointer-events: auto;
} }
.ns-editor--panel { .ns-editor--panel {

View file

@ -509,7 +509,7 @@ input {
.prop-tooltip { .prop-tooltip {
position: absolute; position: absolute;
z-index: 8; z-index: 8;
max-width: 320px; max-width: min(360px, calc(100% - 20px));
min-width: 140px; min-width: 140px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
@ -528,12 +528,39 @@ input {
"Noto Sans SC", system-ui, sans-serif; "Noto Sans SC", system-ui, sans-serif;
} }
.prop-tooltip--pinned {
pointer-events: auto;
z-index: 12;
border-color: color-mix(
in srgb,
var(--dsw-alias-brand-primary, #5e6ad2) 55%,
rgba(255, 255, 255, 0.2)
);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 0 0 1px color-mix(in srgb, var(--dsw-alias-brand-primary, #5e6ad2) 35%, transparent),
0 16px 44px rgba(0, 0, 0, 0.52);
}
.prop-tooltip__label { .prop-tooltip__label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary, #a5adb8); color: var(--dsw-alias-label-secondary, #a5adb8);
margin-bottom: 4px; margin-bottom: 4px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.prop-tooltip__pin {
font-size: 10px;
font-weight: 500;
letter-spacing: 0;
color: var(--dsw-alias-brand-primary, #5e6ad2);
white-space: nowrap;
opacity: 0.92;
} }
.prop-tooltip__value { .prop-tooltip__value {
@ -543,6 +570,8 @@ input {
color: var(--dsw-alias-label-primary, #f4f5f7); color: var(--dsw-alias-label-primary, #f4f5f7);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-height: min(40vh, 280px);
overflow: auto;
} }
.edit-overlay { .edit-overlay {