mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 02:10:45 +08:00
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:
parent
4c9bd5b86f
commit
993fcaa2fd
6 changed files with 273 additions and 67 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue