feat(canvas): 连线自动绕开节点并支持拖拽弯折

This commit is contained in:
2026-10-12 13:35:40 +08:00
parent 5a0cbeb429
commit c429209e50
2 changed files with 189 additions and 13 deletions
+184 -12
View File
@@ -68,6 +68,106 @@ function portDirection(side, fallback) {
}[side] || fallback
}
const EDGE_PAD = 30
function shifted(p, v, d) {
return { x: p.x + v.x * d, y: p.y + v.y * d }
}
function cubicPoint(p0, c1, c2, p1, t) {
const u = 1 - t
return {
x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p1.x,
y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p1.y,
}
}
function expandedNode(n) {
return { x: n.x - EDGE_PAD, y: n.y - EDGE_PAD, w: n.w + EDGE_PAD * 2, h: n.h + EDGE_PAD * 2 }
}
function segmentHitsRect(a, b, r) {
for (let i = 0; i <= 20; i++) {
const t = i / 20
const x = a.x + (b.x - a.x) * t
const y = a.y + (b.y - a.y) * t
if (x > r.x && x < r.x + r.w && y > r.y && y < r.y + r.h) return true
}
return false
}
function polylineHits(points, obstacles) {
for (let i = 0; i < points.length - 1; i++) {
for (const r of obstacles) if (segmentHitsRect(points[i], points[i + 1], r)) return true
}
return false
}
function cubicHits(p0, c1, c2, p1, obstacles) {
for (let i = 1; i < 28; i++) {
const p = cubicPoint(p0, c1, c2, p1, i / 28)
for (const r of obstacles) {
if (p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h) return true
}
}
return false
}
function polylineLength(points) {
let n = 0
for (let i = 0; i < points.length - 1; i++) n += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y)
return n
}
function routePolyline(a, b, obstacles) {
const mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
const candidates = [
[a, b],
[a, { x: b.x, y: a.y }, b],
[a, { x: a.x, y: b.y }, b],
[a, { x: mx, y: a.y }, { x: mx, y: b.y }, b],
[a, { x: a.x, y: my }, { x: b.x, y: my }, b],
]
if (obstacles.length) {
const box = obstacles.reduce((o, r) => ({
x: Math.min(o.x, r.x), y: Math.min(o.y, r.y),
x2: Math.max(o.x2, r.x + r.w), y2: Math.max(o.y2, r.y + r.h),
}), { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity })
for (const y of [box.y - EDGE_PAD, box.y2 + EDGE_PAD]) {
candidates.push([a, { x: a.x, y }, { x: b.x, y }, b])
}
for (const x of [box.x - EDGE_PAD, box.x2 + EDGE_PAD]) {
candidates.push([a, { x, y: a.y }, { x, y: b.y }, b])
}
}
let best = null
let bestScore = Infinity
for (const c of candidates) {
if (polylineHits(c, obstacles)) continue
const score = polylineLength(c) + (c.length - 2) * 80
if (score < bestScore) {
best = c
bestScore = score
}
}
return best || [a, b]
}
function roundedPath(points) {
const pts = points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5)
if (pts.length < 2) return ''
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length - 1; i++) {
const prev = pts[i - 1]
const cur = pts[i]
const next = pts[i + 1]
const d1 = Math.hypot(cur.x - prev.x, cur.y - prev.y)
const d2 = Math.hypot(next.x - cur.x, next.y - cur.y)
const r = Math.min(20, d1 / 2, d2 / 2)
if (r < 1) continue
const inX = cur.x - ((cur.x - prev.x) / d1) * r
const inY = cur.y - ((cur.y - prev.y) / d1) * r
const outX = cur.x + ((next.x - cur.x) / d2) * r
const outY = cur.y + ((next.y - cur.y) / d2) * r
d += ` L ${inX} ${inY} Q ${cur.x} ${cur.y} ${outX} ${outY}`
}
const last = pts[pts.length - 1]
return `${d} L ${last.x} ${last.y}`
}
const edgePaths = computed(() => {
const out = []
for (const e of props.edges) {
@@ -76,13 +176,34 @@ const edgePaths = computed(() => {
if (!a || !b) continue
const p1 = portPoint(a, e.fromSide)
const p2 = portPoint(b, e.toSide)
const d = Math.max(60, Math.hypot(p2.x - p1.x, p2.y - p1.y) * 0.45)
const v1 = portDirection(e.fromSide, { x: 1, y: 0 })
const v2 = portDirection(e.toSide, { x: -1, y: 0 })
const manualBend = Number.isFinite(e.bendX) && Number.isFinite(e.bendY)
? { x: (p1.x + p2.x) / 2 + e.bendX, y: (p1.y + p2.y) / 2 + e.bendY }
: null
const d = Math.max(60, Math.hypot(p2.x - p1.x, p2.y - p1.y) * 0.45)
const c1 = shifted(p1, v1, d)
const c2 = shifted(p2, v2, d)
let path
if (manualBend) {
path = roundedPath([p1, shifted(p1, v1, 30), manualBend, shifted(p2, v2, 30), p2])
} else {
path = `M ${p1.x} ${p1.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${p2.x} ${p2.y}`
const obstacles = props.nodes
.filter((n) => n.id !== a.id && n.id !== b.id)
.map(expandedNode)
if (obstacles.length && cubicHits(p1, c1, c2, p2, obstacles)) {
const start = shifted(p1, v1, EDGE_PAD)
const end = shifted(p2, v2, EDGE_PAD)
path = roundedPath([p1, ...routePolyline(start, end, obstacles), p2])
}
}
out.push({
id: e.id,
to: e.to,
d: `M ${p1.x} ${p1.y} C ${p1.x + v1.x * d} ${p1.y + v1.y * d}, ${p2.x + v2.x * d} ${p2.y + v2.y * d}, ${p2.x} ${p2.y}`,
d: path,
bend: manualBend,
handle: manualBend || { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 },
})
}
return out
@@ -213,16 +334,26 @@ function onPointerDown(e) {
}
}
// 2) 点击连线:切换选中(不再落入平移)
// 2) 点住连线:轻点切换选中,拖动则添加/移动弯折点
const edgeHit = e.target.closest?.('.cv-edge-hit')
if (edgeHit && e.button === 0) {
const edge = props.edges.find((x) => x.id === edgeHit.dataset.edgeId)
if (edge) {
linkSource.value = null
const id = edgeHit.dataset.edgeId
const s = new Set(edgeSel.value)
if (s.has(id)) s.delete(id)
else s.add(id)
edgeSel.value = s
if (s.size) clearSelection()
const p = localXY(e)
const w = screenToWorld(p.x, p.y, vp)
act = {
mode: 'edge', edge,
id: edge.id,
wasSelected: edgeSel.value.has(edge.id),
start: w,
bendX: Number.isFinite(edge.bendX) ? edge.bendX : 0,
bendY: Number.isFinite(edge.bendY) ? edge.bendY : 0,
moved: false,
px: p.x, py: p.y,
}
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
}
return
}
@@ -302,6 +433,18 @@ function applyMove(e) {
}
return
}
if (act.mode === 'edge') {
if (!act.moved) {
if (Math.hypot(p.x - act.px, p.y - act.py) < 4) return
act.moved = true
edgeSel.value = new Set([act.id])
clearSelection()
}
const cur = screenToWorld(p.x, p.y, vp)
act.edge.bendX = Math.round(act.bendX + cur.x - act.start.x)
act.edge.bendY = Math.round(act.bendY + cur.y - act.start.y)
return
}
if (act.mode === 'resize') {
const cur = screenToWorld(p.x, p.y, vp)
const n = act.node
@@ -339,10 +482,22 @@ function onPointerUp(e) {
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
if (a.mode === 'drag' && a.moved) emit('move-end')
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
if (a.mode === 'edge') {
if (a.moved) {
emit('move-end')
} else {
const s = new Set(edgeSel.value)
if (a.wasSelected) s.delete(a.id)
else s.add(a.id)
edgeSel.value = s
if (s.size) clearSelection()
}
}
}
function onPointerCancel() {
if (act?.mode === 'drag' && act.moved) emit('move-end')
if (act?.mode === 'resize') emit('move-end')
if (act?.mode === 'edge' && act.moved) emit('move-end')
act = null
marquee.value = null
panning.value = false
@@ -555,6 +710,13 @@ defineExpose({
:class="{ 'cv-edge--hot': selection.has(p.to), 'cv-edge--sel': edgeSel.has(p.id) }"
:d="p.d"
/>
<circle
v-if="edgeSel.has(p.id) || p.bend"
class="cv-edge-bend"
:cx="p.handle.x"
:cy="p.handle.y"
r="5"
/>
</g>
</svg>
<CanvasNodeView
@@ -571,7 +733,7 @@ defineExpose({
</div>
<div v-if="marquee" class="cv-marquee" :style="marquee" />
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 依次点击两个连接点连线(再点起点/空白/Esc 取消) · 双击笔记编辑</div>
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 依次点击连接点连线(拖线调弧度) · 双击笔记编辑</div>
</div>
</template>
@@ -628,9 +790,19 @@ defineExpose({
.cv-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 16;
stroke-width: 18;
pointer-events: stroke;
cursor: pointer;
cursor: grab;
}
.cv-edge-hit:active {
cursor: grabbing;
}
.cv-edge-bend {
fill: var(--surface-solid);
stroke: var(--m-orange);
stroke-width: 3;
pointer-events: none;
filter: drop-shadow(0 0 6px var(--accent-glow));
}
.cv-marquee {
position: absolute;
+4
View File
@@ -130,6 +130,10 @@ export function normalizeDoc(raw) {
const edge = { id: e.id, from: e.from, to: e.to }
if (['top', 'right', 'bottom', 'left'].includes(e.fromSide)) edge.fromSide = e.fromSide
if (['top', 'right', 'bottom', 'left'].includes(e.toSide)) edge.toSide = e.toSide
if (Number.isFinite(e.bendX) && Number.isFinite(e.bendY)) {
edge.bendX = Math.max(-10000, Math.min(10000, e.bendX))
edge.bendY = Math.max(-10000, Math.min(10000, e.bendY))
}
doc.edges.push(edge)
}
}