feat(canvas): 四向点选连线并统一 10% 缩放步进
为所有画布节点显示四向连接点,点击起点与终点连线并保存端点方向;滚轮、按钮与快捷键按 10% 递增。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -8,8 +8,11 @@ import { thumbUrl } from '../../api'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
node: { type: Object, required: true },
|
node: { type: Object, required: true },
|
||||||
selected: { type: Boolean, default: false },
|
selected: { type: Boolean, default: false },
|
||||||
|
linkSourceSide: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['imgload', 'note-commit', 'node-action'])
|
const emit = defineEmits(['imgload', 'note-commit', 'node-action', 'port-click'])
|
||||||
|
const portSides = ['top', 'right', 'bottom', 'left']
|
||||||
|
const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
|
||||||
|
|
||||||
const imgLoaded = ref(false)
|
const imgLoaded = ref(false)
|
||||||
const noteEl = ref(null)
|
const noteEl = ref(null)
|
||||||
@@ -99,8 +102,12 @@ defineExpose({ startEdit, commitNote })
|
|||||||
:data-node-id="node.id"
|
:data-node-id="node.id"
|
||||||
@dblclick="onSelfDblClick"
|
@dblclick="onSelfDblClick"
|
||||||
>
|
>
|
||||||
<!-- 连线出口:拖到目标节点创建连线(舞台接管手势) -->
|
<!-- 连线出口:点选起点和终点建立连线 -->
|
||||||
<span v-if="node.type !== 'gen'" class="cv-port" data-port="out" />
|
<button v-for="side in portSides" :key="side"
|
||||||
|
type="button" class="cv-port" :class="[`cv-port--${side}`, { 'is-active': linkSourceSide === side }]"
|
||||||
|
:aria-label="`${portLabels[side]}侧连接点`" :aria-pressed="linkSourceSide === side"
|
||||||
|
:title="`${portLabels[side]}侧连接点`"
|
||||||
|
@pointerdown.stop @dblclick.stop @click.stop="emit('port-click', side)" />
|
||||||
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
||||||
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
|
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
|
||||||
<div v-if="selected && node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
|
<div v-if="selected && node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
|
||||||
@@ -273,24 +280,35 @@ html.dark .cv-node__note { color: rgb(255 255 255 / 0.88); }
|
|||||||
|
|
||||||
.cv-port {
|
.cv-port {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -5px;
|
width: 32px;
|
||||||
top: 50%;
|
height: 32px;
|
||||||
transform: translateY(-50%);
|
display: grid;
|
||||||
width: 15px;
|
place-items: center;
|
||||||
height: 15px;
|
background: transparent;
|
||||||
border-radius: 50%;
|
cursor: pointer;
|
||||||
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
|
|
||||||
border: 1.5px solid rgb(255 255 255 / 0.7);
|
|
||||||
box-shadow: 0 2px 10px var(--accent-glow);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.12s;
|
|
||||||
cursor: crosshair;
|
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.cv-node:hover .cv-port,
|
.cv-port::after {
|
||||||
.cv-node.is-selected .cv-port {
|
content: '';
|
||||||
opacity: 1;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 3px solid var(--m-orange);
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / 0.35), 0 0 0 2px rgb(255 255 255 / 0.8);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
|
||||||
|
transform: scale(1.3);
|
||||||
|
box-shadow: 0 0 0 4px rgb(124 92 252 / 0.25), 0 2px 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.cv-port.is-active::after { background: var(--m-orange); }
|
||||||
|
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
|
||||||
|
.cv-port--top { top: 0; left: 50%; transform: translateX(-50%); }
|
||||||
|
.cv-port--right { right: 0; top: 50%; transform: translateY(-50%); }
|
||||||
|
.cv-port--bottom { bottom: 0; left: 50%; transform: translateX(-50%); }
|
||||||
|
.cv-port--left { left: 0; top: 50%; transform: translateY(-50%); }
|
||||||
|
|
||||||
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
|
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
|
||||||
.cv-size {
|
.cv-size {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const stageSize = reactive({ w: 0, h: 0 })
|
|||||||
const selection = ref(new Set())
|
const selection = ref(new Set())
|
||||||
const edgeSel = ref(new Set())
|
const edgeSel = ref(new Set())
|
||||||
const marquee = ref(null)
|
const marquee = ref(null)
|
||||||
const linkLine = ref(null) // 手动连线进行中 {x1,y1,x2,y2} 世界坐标
|
const linkSource = ref(null)
|
||||||
const spaceHeld = ref(false)
|
const spaceHeld = ref(false)
|
||||||
const panning = ref(false)
|
const panning = ref(false)
|
||||||
|
|
||||||
@@ -54,21 +54,35 @@ const nodeById = computed(() => {
|
|||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function portPoint(n, side) {
|
||||||
|
if (side === 'top') return { x: n.x + n.w / 2, y: n.y + 16 }
|
||||||
|
if (side === 'right') return { x: n.x + n.w - 16, y: n.y + n.h / 2 }
|
||||||
|
if (side === 'bottom') return { x: n.x + n.w / 2, y: n.y + n.h - 16 }
|
||||||
|
if (side === 'left') return { x: n.x + 16, y: n.y + n.h / 2 }
|
||||||
|
return { x: n.x + n.w / 2, y: n.y + n.h / 2 }
|
||||||
|
}
|
||||||
|
function portDirection(side, fallback) {
|
||||||
|
return {
|
||||||
|
top: { x: 0, y: -1 }, right: { x: 1, y: 0 },
|
||||||
|
bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 },
|
||||||
|
}[side] || fallback
|
||||||
|
}
|
||||||
|
|
||||||
const edgePaths = computed(() => {
|
const edgePaths = computed(() => {
|
||||||
const out = []
|
const out = []
|
||||||
for (const e of props.edges) {
|
for (const e of props.edges) {
|
||||||
const a = nodeById.value.get(e.from)
|
const a = nodeById.value.get(e.from)
|
||||||
const b = nodeById.value.get(e.to)
|
const b = nodeById.value.get(e.to)
|
||||||
if (!a || !b) continue
|
if (!a || !b) continue
|
||||||
const x1 = a.x + a.w / 2
|
const p1 = portPoint(a, e.fromSide)
|
||||||
const y1 = a.y + a.h / 2
|
const p2 = portPoint(b, e.toSide)
|
||||||
const x2 = b.x + b.w / 2
|
const d = Math.max(60, Math.hypot(p2.x - p1.x, p2.y - p1.y) * 0.45)
|
||||||
const y2 = b.y + b.h / 2
|
const v1 = portDirection(e.fromSide, { x: 1, y: 0 })
|
||||||
const dx = Math.max(60, Math.abs(x2 - x1) * 0.45)
|
const v2 = portDirection(e.toSide, { x: -1, y: 0 })
|
||||||
out.push({
|
out.push({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
to: e.to,
|
to: e.to,
|
||||||
d: `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
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}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -78,13 +92,6 @@ const worldStyle = computed(() => ({
|
|||||||
transform: `translate3d(${vp.x}px, ${vp.y}px, 0) scale(${vp.scale})`,
|
transform: `translate3d(${vp.x}px, ${vp.y}px, 0) scale(${vp.scale})`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 手动连线进行中的预览路径
|
|
||||||
const linkPath = computed(() => {
|
|
||||||
const l = linkLine.value
|
|
||||||
if (!l) return ''
|
|
||||||
const dx = Math.max(60, Math.abs(l.x2 - l.x1) * 0.45)
|
|
||||||
return `M ${l.x1} ${l.y1} C ${l.x1 + dx} ${l.y1}, ${l.x2 - dx} ${l.y2}, ${l.x2} ${l.y2}`
|
|
||||||
})
|
|
||||||
const gridStyle = computed(() => {
|
const gridStyle = computed(() => {
|
||||||
const cell = Math.max(14, 24 * vp.scale)
|
const cell = Math.max(14, 24 * vp.scale)
|
||||||
return {
|
return {
|
||||||
@@ -206,25 +213,6 @@ function onPointerDown(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 连线出口:拖到目标节点建边
|
|
||||||
const port = e.target.closest?.('[data-port]')
|
|
||||||
if (port && e.button === 0) {
|
|
||||||
const node = hitNode(e)
|
|
||||||
if (node) {
|
|
||||||
clearEdgeSel()
|
|
||||||
setSelection(new Set([node.id]))
|
|
||||||
act = { mode: 'link', fromId: node.id }
|
|
||||||
linkLine.value = {
|
|
||||||
x1: node.x + node.w,
|
|
||||||
y1: node.y + node.h / 2,
|
|
||||||
x2: node.x + node.w,
|
|
||||||
y2: node.y + node.h / 2,
|
|
||||||
}
|
|
||||||
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 点击连线:切换选中(不再落入平移)
|
// 2) 点击连线:切换选中(不再落入平移)
|
||||||
const edgeHit = e.target.closest?.('.cv-edge-hit')
|
const edgeHit = e.target.closest?.('.cv-edge-hit')
|
||||||
if (edgeHit && e.button === 0) {
|
if (edgeHit && e.button === 0) {
|
||||||
@@ -238,6 +226,7 @@ function onPointerDown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const node = hitNode(e)
|
const node = hitNode(e)
|
||||||
|
if (!node) linkSource.value = null
|
||||||
const p = localXY(e)
|
const p = localXY(e)
|
||||||
|
|
||||||
const wantPan =
|
const wantPan =
|
||||||
@@ -326,12 +315,6 @@ function applyMove(e) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (act.mode === 'link' && linkLine.value) {
|
|
||||||
const cur = screenToWorld(p.x, p.y, vp)
|
|
||||||
linkLine.value.x2 = cur.x
|
|
||||||
linkLine.value.y2 = cur.y
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (act.mode === 'marquee') {
|
if (act.mode === 'marquee') {
|
||||||
marquee.value = {
|
marquee.value = {
|
||||||
x: Math.min(act.px, p.x),
|
x: Math.min(act.px, p.x),
|
||||||
@@ -355,27 +338,36 @@ function onPointerUp(e) {
|
|||||||
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
|
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
|
||||||
if (a.mode === 'drag' && a.moved) emit('move-end')
|
if (a.mode === 'drag' && a.moved) emit('move-end')
|
||||||
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
|
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
|
||||||
if (a.mode === 'link') {
|
|
||||||
const target = hitNode(e)
|
|
||||||
if (target && target.id !== a.fromId) emit('link-create', { from: a.fromId, to: target.id })
|
|
||||||
linkLine.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onPointerCancel() {
|
function onPointerCancel() {
|
||||||
if (act?.mode === 'drag' && act.moved) emit('move-end')
|
if (act?.mode === 'drag' && act.moved) emit('move-end')
|
||||||
if (act?.mode === 'resize') emit('move-end')
|
if (act?.mode === 'resize') emit('move-end')
|
||||||
act = null
|
act = null
|
||||||
marquee.value = null
|
marquee.value = null
|
||||||
linkLine.value = null
|
|
||||||
panning.value = false
|
panning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPortClick(id, side) {
|
||||||
|
if (linkSource.value && !nodeById.value.has(linkSource.value.id)) linkSource.value = null
|
||||||
|
if (!linkSource.value) {
|
||||||
|
clearEdgeSel()
|
||||||
|
linkSource.value = { id, side }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (linkSource.value.id === id) {
|
||||||
|
linkSource.value = linkSource.value.side === side ? null : { id, side }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('link-create', {
|
||||||
|
from: linkSource.value.id, to: id,
|
||||||
|
fromSide: linkSource.value.side, toSide: side,
|
||||||
|
})
|
||||||
|
linkSource.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 取消进行中的连线(Esc)
|
// 取消进行中的连线(Esc)
|
||||||
function cancelLink() {
|
function cancelLink() {
|
||||||
if (act?.mode === 'link') {
|
linkSource.value = null
|
||||||
act = null
|
|
||||||
linkLine.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回并清空选中的连线(父级 Delete 处理用)
|
// 返回并清空选中的连线(父级 Delete 处理用)
|
||||||
@@ -391,12 +383,37 @@ function hasSelectedEdges() {
|
|||||||
return edgeSel.value.size > 0
|
return edgeSel.value.size > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextZoom(scale, direction) {
|
||||||
|
if (direction < 0 && scale < 0.1) return scale
|
||||||
|
const level = scale * 10
|
||||||
|
const step = direction > 0 ? Math.floor(level + 1e-6) + 1 : Math.ceil(level - 1e-6) - 1
|
||||||
|
return Math.min(4, Math.max(0.1, step / 10))
|
||||||
|
}
|
||||||
|
let wheelAccum = 0
|
||||||
|
let lastWheelAt = 0
|
||||||
function onWheel(e) {
|
function onWheel(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const p = localXY(e)
|
const p = localXY(e)
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
Object.assign(vp, zoomAt(vp, p.x, p.y, vp.scale * Math.exp(-e.deltaY * 0.0018)))
|
const now = Date.now()
|
||||||
|
if (now - lastWheelAt > 200) wheelAccum = 0
|
||||||
|
lastWheelAt = now
|
||||||
|
const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1)
|
||||||
|
let steps
|
||||||
|
if (Math.abs(delta) >= 80) {
|
||||||
|
steps = Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / 120))
|
||||||
|
wheelAccum = 0
|
||||||
} else {
|
} else {
|
||||||
|
wheelAccum += delta
|
||||||
|
steps = Math.trunc(wheelAccum / 80)
|
||||||
|
wheelAccum -= steps * 80
|
||||||
|
}
|
||||||
|
let scale = vp.scale
|
||||||
|
for (let i = 0; i < Math.min(Math.abs(steps), 20); i++) scale = nextZoom(scale, -steps)
|
||||||
|
if (scale !== vp.scale) Object.assign(vp, zoomAt(vp, p.x, p.y, scale))
|
||||||
|
else return
|
||||||
|
} else {
|
||||||
|
wheelAccum = 0
|
||||||
vp.x -= e.deltaX
|
vp.x -= e.deltaX
|
||||||
vp.y -= e.deltaY
|
vp.y -= e.deltaY
|
||||||
}
|
}
|
||||||
@@ -440,11 +457,13 @@ function setViewport(v) {
|
|||||||
Object.assign(vp, v)
|
Object.assign(vp, v)
|
||||||
notifyViewport()
|
notifyViewport()
|
||||||
}
|
}
|
||||||
function zoomBy(factor) {
|
function zoomStep(direction) {
|
||||||
const r = stageEl.value?.getBoundingClientRect()
|
const r = stageEl.value?.getBoundingClientRect()
|
||||||
const cx = r ? r.width / 2 : 0
|
const cx = r ? r.width / 2 : 0
|
||||||
const cy = r ? r.height / 2 : 0
|
const cy = r ? r.height / 2 : 0
|
||||||
Object.assign(vp, zoomAt(vp, cx, cy, vp.scale * factor))
|
const scale = nextZoom(vp.scale, direction)
|
||||||
|
if (scale === vp.scale) return
|
||||||
|
Object.assign(vp, zoomAt(vp, cx, cy, scale))
|
||||||
notifyViewport()
|
notifyViewport()
|
||||||
}
|
}
|
||||||
function centerOn(x, y) {
|
function centerOn(x, y) {
|
||||||
@@ -504,7 +523,7 @@ function screenRectToWorld(r) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
|
setViewport, zoomStep, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
|
||||||
cancelLink, consumeSelectedEdges, hasSelectedEdges, fitToContent, selectIds, bumpNode,
|
cancelLink, consumeSelectedEdges, hasSelectedEdges, fitToContent, selectIds, bumpNode,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -536,13 +555,14 @@ defineExpose({
|
|||||||
:d="p.d"
|
:d="p.d"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
<path v-if="linkLine" class="cv-linkline" :d="linkPath" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<CanvasNodeView
|
<CanvasNodeView
|
||||||
v-for="n in visibleNodes"
|
v-for="n in visibleNodes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
:node="n"
|
:node="n"
|
||||||
:selected="selection.has(n.id)"
|
:selected="selection.has(n.id)"
|
||||||
|
:link-source-side="linkSource?.id === n.id ? linkSource.side : ''"
|
||||||
|
@port-click="(side) => onPortClick(n.id, side)"
|
||||||
@imgload="(s) => emit('img-size', { id: n.id, ...s })"
|
@imgload="(s) => emit('img-size', { id: n.id, ...s })"
|
||||||
@note-commit="(text) => emit('note-commit', { id: n.id, text })"
|
@note-commit="(text) => emit('note-commit', { id: n.id, text })"
|
||||||
@node-action="(a) => emit('node-action', { ...a, id: n.id })"
|
@node-action="(a) => emit('node-action', { ...a, id: n.id })"
|
||||||
@@ -550,7 +570,7 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="marquee" class="cv-marquee" :style="marquee" />
|
<div v-if="marquee" class="cv-marquee" :style="marquee" />
|
||||||
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 拖节点右侧圆点连线 · 点击连线可选中删除 · 双击笔记编辑</div>
|
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 依次点击两个节点的连接点连线(Esc 取消) · 双击笔记编辑</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -608,13 +628,6 @@ defineExpose({
|
|||||||
pointer-events: stroke;
|
pointer-events: stroke;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cv-linkline {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--m-orange);
|
|
||||||
stroke-width: 2.5;
|
|
||||||
stroke-dasharray: 8 6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.cv-marquee {
|
.cv-marquee {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 2px dashed rgb(124 92 252 / 0.5);
|
border: 2px dashed rgb(124 92 252 / 0.5);
|
||||||
|
|||||||
@@ -127,7 +127,10 @@ export function normalizeDoc(raw) {
|
|||||||
if (!e || typeof e.id !== 'string') continue
|
if (!e || typeof e.id !== 'string') continue
|
||||||
if (typeof e.from !== 'string' || typeof e.to !== 'string') continue
|
if (typeof e.from !== 'string' || typeof e.to !== 'string') continue
|
||||||
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue
|
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue
|
||||||
doc.edges.push({ id: e.id, from: e.from, to: e.to })
|
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
|
||||||
|
doc.edges.push(edge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return doc
|
return doc
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ function duplicateSelection() {
|
|||||||
doc.nodes.push(...clones)
|
doc.nodes.push(...clones)
|
||||||
for (const e of [...doc.edges]) {
|
for (const e of [...doc.edges]) {
|
||||||
if (idMap[e.from] && idMap[e.to]) {
|
if (idMap[e.from] && idMap[e.to]) {
|
||||||
doc.edges.push({ id: newId('e'), from: idMap[e.from], to: idMap[e.to] })
|
doc.edges.push({ ...e, id: newId('e'), from: idMap[e.from], to: idMap[e.to] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectionIds.value = clones.map((c) => c.id)
|
selectionIds.value = clones.map((c) => c.id)
|
||||||
@@ -377,9 +377,9 @@ function deleteSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 手动连线 ----
|
// ---- 手动连线 ----
|
||||||
function onLinkCreate({ from, to }) {
|
function onLinkCreate({ from, to, fromSide, toSide }) {
|
||||||
if (doc.edges.some((e) => e.from === from && e.to === to)) return // 去重
|
if (doc.edges.some((e) => e.from === from && e.to === to)) return // 去重
|
||||||
doc.edges.push({ id: newId('e'), from, to })
|
doc.edges.push({ id: newId('e'), from, to, fromSide, toSide })
|
||||||
pushHistory()
|
pushHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,9 +690,9 @@ function onKeydown(e) {
|
|||||||
previewSrc.value = ''
|
previewSrc.value = ''
|
||||||
pickerOpen.value = false
|
pickerOpen.value = false
|
||||||
} else if (e.key === '+' || e.key === '=') {
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
stage.value?.zoomBy(1.2)
|
stage.value?.zoomStep(1)
|
||||||
} else if (e.key === '-') {
|
} else if (e.key === '-') {
|
||||||
stage.value?.zoomBy(1 / 1.2)
|
stage.value?.zoomStep(-1)
|
||||||
} else if (e.key === '0') {
|
} else if (e.key === '0') {
|
||||||
stage.value?.setViewport({ x: 0, y: 0, scale: 1 })
|
stage.value?.setViewport({ x: 0, y: 0, scale: 1 })
|
||||||
} else if (e.key === '1') {
|
} else if (e.key === '1') {
|
||||||
@@ -770,9 +770,9 @@ const saveLabel = computed(() => ({
|
|||||||
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
||||||
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
||||||
<span class="cv-sep" />
|
<span class="cv-sep" />
|
||||||
<button class="cv-btn" title="缩小" @click="stage?.zoomBy(1 / 1.2)">−</button>
|
<button class="cv-btn" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
|
||||||
<span class="cv-zoom">{{ scalePct }}%</span>
|
<span class="cv-zoom">{{ scalePct }}%</span>
|
||||||
<button class="cv-btn" title="放大" @click="stage?.zoomBy(1.2)">+</button>
|
<button class="cv-btn" title="放大 10%" @click="stage?.zoomStep(1)">+</button>
|
||||||
<button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: 1 })">⌂</button>
|
<button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: 1 })">⌂</button>
|
||||||
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
|
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
|
||||||
<span class="cv-sep" />
|
<span class="cv-sep" />
|
||||||
@@ -857,7 +857,7 @@ const saveLabel = computed(() => ({
|
|||||||
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
|
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
|
||||||
|
|
||||||
<div class="cv-tips">
|
<div class="cv-tips">
|
||||||
双击空白建笔记 · 拖右侧圆点连线 · 选中后右下角缩放 · Ctrl+D 复制 ·
|
双击空白建笔记 · 依次点击两个连接点连线 · 选中后右下角缩放 · Ctrl+D 复制 ·
|
||||||
本地图片直接拖入或 Ctrl+V 粘贴 · 按 1 适应全部内容
|
本地图片直接拖入或 Ctrl+V 粘贴 · 按 1 适应全部内容
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user