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({
label,
value,
x,
y,
pinned,
hostW,
hostH,
onUnpin,
}: {
label: string;
value: string;
x: 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 (
<div className="prop-tooltip" style={{ left: x, top: y }} role="tooltip">
<div className="prop-tooltip__label">{label}</div>
<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>
);

View file

@ -144,7 +144,12 @@ export function TopologyCanvas() {
value: string;
x: number;
y: number;
pinned: boolean;
nodeId: string;
fieldKey: string;
} | null>(null);
const propTipRef = useRef(propTip);
propTipRef.current = propTip;
const [linkPreview, setLinkPreview] = useState<{
fromId: string;
x: number;
@ -283,7 +288,60 @@ export function TopologyCanvas() {
}
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(() => {
@ -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) {
const hit = hitTest(world.x, world.y);
setHoverId(hit);
if (cardView && hit && viewport.zoom >= 0.35 && propEditId !== hit) {
const n = nodes.find((x) => x.id === hit);
const p = n ? posOf(hit) : null;
if (n && p) {
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: 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);
}
if (propTipRef.current?.pinned) return;
const found = hitCardField(e.clientX, e.clientY, world);
if (found) {
setPropTip({
label: found.field.label,
value: found.field.fullValue,
x: found.tipX,
y: found.tipY,
pinned: false,
nodeId: found.nodeId,
fieldKey: found.field.key,
});
} else {
setPropTip(null);
}
@ -1373,13 +1404,30 @@ export function TopologyCanvas() {
} else if (mode === "node" && fromId && !moved) {
setSelection([fromId]);
if (cardView) {
setExpandedCardId((prev) => (prev === fromId ? null : fromId));
if (propEditId && propEditId !== fromId) setPropEditId(null);
const found = hitCardField(e.clientX, e.clientY, world);
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) {
setExpandedCardId(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";
@ -1392,7 +1440,14 @@ export function TopologyCanvas() {
setDropForbidden(false);
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) => {
if (locked) return;
const world = toWorld(e.clientX, e.clientY);
@ -1783,6 +1838,10 @@ export function TopologyCanvas() {
value={propTip.value}
x={propTip.x}
y={propTip.y}
pinned={propTip.pinned}
hostW={hostSize.w}
hostH={hostSize.h}
onUnpin={() => setPropTip(null)}
/>
) : null}

View file

@ -77,7 +77,7 @@ const zh: CanvasUi = {
card: "卡片",
topology: "拓扑",
bubbleTitle: "气泡视图:精简标题",
cardTitle: "卡片视图:单击展开全文 · 悬停属性看详情 · 双击编辑",
cardTitle: "卡片:单击属性钉住浮层 · 右键释放 · 双击编辑",
topologyTitle: "拓扑视图:路由器图标 + 链路",
unpin: "解钉",
unpinTitle: "取消钉住:让选中节点重新参与自动布局",
@ -127,7 +127,7 @@ const en: CanvasUi = {
card: "Card",
topology: "Topology",
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",
unpin: "Unpin",
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 };
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(
description: Record<string, unknown> | null | undefined,
): KvRow[] {
@ -160,6 +169,12 @@ export function NodePropEditor({
? ({ "--ns-brand": accent } as CSSProperties)
: undefined
}
onPointerDown={(e) => {
if (variant === "overlay") e.stopPropagation();
}}
onWheel={(e) => {
if (variant === "overlay") e.stopPropagation();
}}
>
<div
className="ns-editor__header"
@ -394,18 +409,24 @@ function DescriptionListEditor({
}}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
/>
<input
<textarea
className="ns-kv__val"
value={row.value}
placeholder="内容"
aria-label="属性值"
rows={Math.min(10, Math.max(3, Math.ceil(row.value.length / 36)))}
onChange={(e) => {
const value = e.target.value;
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, value } : r)),
);
autoResizeTextarea(e.target);
}}
onFocus={(e) => autoResizeTextarea(e.target)}
onBlur={() => onCommit(descriptionFromRows(rowsRef.current))}
ref={(el) => {
if (el) autoResizeTextarea(el);
}}
/>
<button
type="button"
@ -525,10 +546,22 @@ function FieldRow({
{def.kind === "textarea" || def.kind === "links" ? (
<textarea
key={`${def.id}-${node.id}-${node.version}`}
className={
def.id === "note" || seed.length > 80 ? "ns-textarea--lg" : undefined
}
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 每行一条" : ""}
onFocus={(e) => autoResizeTextarea(e.target)}
onInput={(e) => autoResizeTextarea(e.currentTarget)}
onBlur={(e) => onCommit(e.target.value)}
ref={(el) => {
if (el) autoResizeTextarea(el);
}}
/>
) : def.kind === "datetime" ? (
<input
@ -575,9 +608,9 @@ export function NodePropOverlay({
applyOps,
screenX,
screenY,
zoom,
zoom: _zoom,
accent,
width,
width: _width,
showStylePreset,
}: {
node: MindNode;
@ -589,14 +622,16 @@ export function NodePropOverlay({
width: number;
showStylePreset?: boolean;
}) {
// Keep editor at screen size (ignore canvas zoom / card width) so long text is editable.
const panelW = OVERLAY_EDITOR_W;
return (
<div
className="ns-overlay"
className="ns-overlay ns-overlay--roomy"
style={{
left: screenX,
top: screenY,
width,
transform: `translate(-50%, -50%) scale(${zoom})`,
width: panelW,
transform: "translate(-50%, -50%)",
}}
>
<NodePropEditor

View file

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

View file

@ -509,7 +509,7 @@ input {
.prop-tooltip {
position: absolute;
z-index: 8;
max-width: 320px;
max-width: min(360px, calc(100% - 20px));
min-width: 140px;
padding: 10px 12px;
border-radius: 12px;
@ -528,12 +528,39 @@ input {
"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 {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary, #a5adb8);
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 {
@ -543,6 +570,8 @@ input {
color: var(--dsw-alias-label-primary, #f4f5f7);
white-space: pre-wrap;
word-break: break-word;
max-height: min(40vh, 280px);
overflow: auto;
}
.edit-overlay {