diff --git a/apps/web/src/PropTooltip.tsx b/apps/web/src/PropTooltip.tsx index 36d84a6..537dd8d 100644 --- a/apps/web/src/PropTooltip.tsx +++ b/apps/web/src/PropTooltip.tsx @@ -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(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 ( -
-
{label}
+
{ + if (!pinned) return; + e.preventDefault(); + e.stopPropagation(); + onUnpin?.(); + }} + onPointerDown={(e) => { + if (pinned) e.stopPropagation(); + }} + > +
+ {label} + {pinned ? ( + 钉住 · 右键释放 + ) : null} +
{value}
); diff --git a/apps/web/src/TopologyCanvas.tsx b/apps/web/src/TopologyCanvas.tsx index 9d1c45a..acaf3e1 100644 --- a/apps/web/src/TopologyCanvas.tsx +++ b/apps/web/src/TopologyCanvas.tsx @@ -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} diff --git a/apps/web/src/canvas-ui-text.ts b/apps/web/src/canvas-ui-text.ts index 20f0492..9269c99 100644 --- a/apps/web/src/canvas-ui-text.ts +++ b/apps/web/src/canvas-ui-text.ts @@ -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", diff --git a/apps/web/src/node-props/NodePropEditor.tsx b/apps/web/src/node-props/NodePropEditor.tsx index 6fbce70..ea3866a 100644 --- a/apps/web/src/node-props/NodePropEditor.tsx +++ b/apps/web/src/node-props/NodePropEditor.tsx @@ -24,6 +24,15 @@ type ApplyOps = (ops: Op[], summary?: string) => void | Promise; 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 | 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(); + }} >
onCommit(descriptionFromRows(rowsRef.current))} /> - { 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); + }} />