feat(canvas): manual node linking + no-model hint + render-hardening

- drag from a node's side port onto another node to create an edge
  (dashed live preview, dedupe, self-link rejected); click an edge to
  select (highlighted), Delete removes it, Ctrl+Z restores — all in
  undo history and autosave
- generate panel shows an explicit hint when the site has no image
  models instead of an empty dropdown
- render hardening: viewport-culling size now has a hard floor
  (max(stageSize, window, 1600x900)) so a delayed/degenerate
  ResizeObserver reading can never cull nodes out of the DOM; stage
  listeners attach with retry + rAF self-healing measure; pointer
  capture wrapped in try/catch for synthetic pointers
This commit is contained in:
dokploy-bot
2026-09-20 00:06:08 +08:00
parent 38367c36ab
commit 910651ddba
3 changed files with 224 additions and 26 deletions
@@ -99,6 +99,8 @@ 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" />
<!-- 图片节点 --> <!-- 图片节点 -->
<template v-if="node.type === 'image'"> <template v-if="node.type === 'image'">
<img <img
@@ -241,4 +243,24 @@ defineExpose({ startEdit, commitNote })
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.cv-port {
position: absolute;
right: -8px;
top: 50%;
transform: translateY(-50%);
width: 13px;
height: 13px;
border-radius: 50%;
background: #7c6cff;
border: 2.5px solid #0e1015;
opacity: 0;
transition: opacity 0.12s;
cursor: crosshair;
z-index: 2;
}
.cv-node:hover .cv-port,
.cv-node.is-selected .cv-port {
opacity: 1;
}
</style> </style>
+164 -15
View File
@@ -21,9 +21,10 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'move-end', // 拖拽结束(父级 push undo) 'move-end', // 拖拽结束(父级 push undo)
'selection', // 选中变化 -> ids[] 'selection', // 选中变化 -> ids[]
'link-create', // 手动连线完成 -> { from, to }
'node-action', // 双击图片等 -> { type:'preview', id } 'node-action', // 双击图片等 -> { type:'preview', id }
'note-commit', // 笔记提交 -> { id, text } 'note-commit', // 笔记文本提交 -> { id, text }
'img-size', // 图片尺寸校正 -> { id, w, h } 'img-size', // 图片真实尺寸校正 -> { id, w, h }
'viewport-rest', // 视口静止 700ms -> viewport 'viewport-rest', // 视口静止 700ms -> viewport
'pan-zoom', // 视口变化(用于百分比显示,高频) 'pan-zoom', // 视口变化(用于百分比显示,高频)
]) ])
@@ -32,13 +33,20 @@ const stageEl = ref(null)
const vp = reactive({ x: 0, y: 0, scale: 1 }) const vp = reactive({ x: 0, y: 0, scale: 1 })
const stageSize = reactive({ w: 0, h: 0 }) const stageSize = reactive({ w: 0, h: 0 })
const selection = ref(new Set()) const selection = 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 spaceHeld = ref(false) const spaceHeld = ref(false)
const panning = ref(false) const panning = ref(false)
const visibleNodes = computed(() => // 视口剔除。尺寸强下限:Teleport/过渡时序下 ResizeObserver 可能给出
nodesInViewport(props.nodes, vp, stageSize.w, stageSize.h), // 0/极小/延迟的尺寸,任何一种都不允许把节点剔除出渲染(剔除只是性能
) // 优化,正确性优先;多挂载一圈屏幕外节点对千级节点也毫无压力)。
const visibleNodes = computed(() => {
const w = Math.max(stageSize.w || 0, window.innerWidth || 0, 1600)
const h = Math.max(stageSize.h || 0, window.innerHeight || 0, 900)
return nodesInViewport(props.nodes, vp, w, h)
})
const nodeById = computed(() => { const nodeById = computed(() => {
const m = new Map() const m = new Map()
for (const n of props.nodes) m.set(n.id, n) for (const n of props.nodes) m.set(n.id, n)
@@ -68,6 +76,14 @@ const edgePaths = computed(() => {
const worldStyle = computed(() => ({ 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 {
@@ -78,19 +94,61 @@ const gridStyle = computed(() => {
// ---- 尺寸/键盘监听 ---- // ---- 尺寸/键盘监听 ----
let ro = null let ro = null
onMounted(() => {
ro = new ResizeObserver((entries) => { function measure() {
const r = entries[0].contentRect const r = stageEl.value?.getBoundingClientRect()
if (r && r.width > 0 && r.height > 0) {
stageSize.w = r.width stageSize.w = r.width
stageSize.h = r.height stageSize.h = r.height
}
}
function attachStageBits() {
if (!stageEl.value) return false
measure()
if (!ro) {
ro = new ResizeObserver((entries) => {
const r = entries[0]?.contentRect
if (r) {
stageSize.w = r.width
stageSize.h = r.height
}
}) })
ro.observe(stageEl.value) ro.observe(stageEl.value)
}
stageEl.value.addEventListener('wheel', onWheel, { passive: false }) stageEl.value.addEventListener('wheel', onWheel, { passive: false })
window.addEventListener('keydown', onSpaceDown) window.addEventListener('keydown', onSpaceDown)
window.addEventListener('keyup', onSpaceUp) window.addEventListener('keyup', onSpaceUp)
syncZTop() syncZTop()
return true
}
let attachTimer = null
onMounted(() => {
// Teleport + 路由过渡下模板 ref/尺寸偶发延迟:重试到挂上为止
if (!attachStageBits()) {
let tries = 0
clearInterval(attachTimer)
attachTimer = setInterval(() => {
tries += 1
if (attachStageBits() || tries > 20) clearInterval(attachTimer)
}, 100)
}
// rAF 自愈:挂载初期持续测量,直到拿到非零尺寸(过渡动画结束后必命中)
const heal = () => {
if (stageSize.w > 0 && stageSize.h > 0) return
measure()
requestAnimationFrame(heal)
}
requestAnimationFrame(heal)
// 调试信标(生产可留:只读状态)
window.__cvDebug = () => ({
stageSize: { ...stageSize },
hasEl: !!stageEl.value,
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(attachTimer)
ro?.disconnect() ro?.disconnect()
stageEl.value?.removeEventListener('wheel', onWheel) stageEl.value?.removeEventListener('wheel', onWheel)
window.removeEventListener('keydown', onSpaceDown) window.removeEventListener('keydown', onSpaceDown)
@@ -127,6 +185,38 @@ let act = null // { mode: 'pan'|'drag'|'marquee', ... }
function onPointerDown(e) { function onPointerDown(e) {
// 文本编辑中完全放行 // 文本编辑中完全放行
if (e.target.closest?.('.cv-node--note.is-editing')) return if (e.target.closest?.('.cv-node--note.is-editing')) return
// 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) 点击连线:切换选中(不再落入平移)
const edgeHit = e.target.closest?.('.cv-edge-hit')
if (edgeHit && e.button === 0) {
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()
return
}
const node = hitNode(e) const node = hitNode(e)
const p = localXY(e) const p = localXY(e)
@@ -135,12 +225,13 @@ function onPointerDown(e) {
if (wantPan) { if (wantPan) {
panning.value = true panning.value = true
act = { mode: 'pan', px: p.x, py: p.y, vx: vp.x, vy: vp.y } act = { mode: 'pan', px: p.x, py: p.y, vx: vp.x, vy: vp.y }
stageEl.value.setPointerCapture(e.pointerId) try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
return return
} }
if (e.button !== 0) return if (e.button !== 0) return
if (node) { if (node) {
clearEdgeSel()
if (e.shiftKey) { if (e.shiftKey) {
const s = new Set(selection.value) const s = new Set(selection.value)
if (s.has(node.id)) s.delete(node.id) if (s.has(node.id)) s.delete(node.id)
@@ -201,6 +292,12 @@ 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),
@@ -223,14 +320,41 @@ function onPointerUp(e) {
panning.value = false panning.value = false
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 === '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')
act = null act = null
marquee.value = null marquee.value = null
linkLine.value = null
panning.value = false panning.value = false
} }
// 取消进行中的连线(Esc)
function cancelLink() {
if (act?.mode === 'link') {
act = null
linkLine.value = null
}
}
// 返回并清空选中的连线(父级 Delete 处理用)
function consumeSelectedEdges() {
const ids = [...edgeSel.value]
edgeSel.value = new Set()
return ids
}
function clearEdgeSel() {
if (edgeSel.value.size) edgeSel.value = new Set()
}
function hasSelectedEdges() {
return edgeSel.value.size > 0
}
function onWheel(e) { function onWheel(e) {
e.preventDefault() e.preventDefault()
const p = localXY(e) const p = localXY(e)
@@ -306,6 +430,7 @@ function screenRectToWorld(r) {
} }
defineExpose({ defineExpose({
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp, setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
cancelLink, consumeSelectedEdges, hasSelectedEdges,
}) })
</script> </script>
@@ -323,13 +448,19 @@ defineExpose({
> >
<div class="cv-world" :style="worldStyle"> <div class="cv-world" :style="worldStyle">
<svg class="cv-edges" aria-hidden="true"> <svg class="cv-edges" aria-hidden="true">
<g v-for="p in edgePaths" :key="p.id">
<path <path
v-for="p in edgePaths" class="cv-edge-hit"
:key="p.id" :data-edge-id="p.id"
:d="p.d" :d="p.d"
class="cv-edge"
:class="{ 'cv-edge--hot': selection.has(p.to) }"
/> />
<path
class="cv-edge"
:class="{ 'cv-edge--hot': selection.has(p.to), 'cv-edge--sel': edgeSel.has(p.id) }"
:d="p.d"
/>
</g>
<path v-if="linkLine" class="cv-linkline" :d="linkPath" />
</svg> </svg>
<CanvasNodeView <CanvasNodeView
v-for="n in visibleNodes" v-for="n in visibleNodes"
@@ -343,7 +474,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+滚轮缩放 · Shift+拖拽框选 · 双击笔记编辑 · 按住空格+拖拽平移</div> <div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 拖节点右侧圆点连线 · 点击连线可选中删除 · 双击笔记编辑</div>
</div> </div>
</template> </template>
@@ -375,7 +506,6 @@ defineExpose({
width: 1px; width: 1px;
height: 1px; height: 1px;
overflow: visible; overflow: visible;
pointer-events: none;
} }
.cv-edge { .cv-edge {
fill: none; fill: none;
@@ -386,6 +516,25 @@ defineExpose({
stroke: #7c6cff; stroke: #7c6cff;
stroke-width: 2; stroke-width: 2;
} }
.cv-edge--sel {
stroke: #ff7b8f;
stroke-width: 2.5;
filter: drop-shadow(0 0 3px rgb(255 123 143 / 0.6));
}
.cv-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 16;
pointer-events: stroke;
cursor: pointer;
}
.cv-linkline {
fill: none;
stroke: #a99dff;
stroke-width: 2;
stroke-dasharray: 7 6;
pointer-events: none;
}
.cv-marquee { .cv-marquee {
position: absolute; position: absolute;
border: 1px solid #7c6cff; border: 1px solid #7c6cff;
+31 -4
View File
@@ -323,14 +323,28 @@ function viewportCenterWorld() {
} }
function deleteSelection() { function deleteSelection() {
if (!selectionIds.value.length) return
const dead = new Set(selectionIds.value) const dead = new Set(selectionIds.value)
const deadEdges = stage.value?.consumeSelectedEdges() || []
if (!dead.size && !deadEdges.length) return
if (dead.size) {
doc.nodes = doc.nodes.filter((n) => !dead.has(n.id)) doc.nodes = doc.nodes.filter((n) => !dead.has(n.id))
doc.edges = doc.edges.filter((e) => !dead.has(e.from) && !dead.has(e.to)) doc.edges = doc.edges.filter((e) => !dead.has(e.from) && !dead.has(e.to))
}
if (deadEdges.length) {
const gone = new Set(deadEdges)
doc.edges = doc.edges.filter((e) => !gone.has(e.id))
}
selectionIds.value = [] selectionIds.value = []
pushHistory() pushHistory()
} }
// ---- 手动连线 ----
function onLinkCreate({ from, to }) {
if (doc.edges.some((e) => e.from === from && e.to === to)) return // 去重
doc.edges.push({ id: newId('e'), from, to })
pushHistory()
}
// ---- 画布管理 ---- // ---- 画布管理 ----
async function switchCanvas(id) { async function switchCanvas(id) {
@@ -557,6 +571,7 @@ function onKeydown(e) {
e.preventDefault() e.preventDefault()
deleteSelection() deleteSelection()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
stage.value?.cancelLink()
stage.value?.clearSelection() stage.value?.clearSelection()
previewSrc.value = '' previewSrc.value = ''
pickerOpen.value = false pickerOpen.value = false
@@ -660,6 +675,7 @@ const saveLabel = computed(() => ({
:edges="doc.edges" :edges="doc.edges"
@selection="onSelection" @selection="onSelection"
@move-end="onMoveEnd" @move-end="onMoveEnd"
@link-create="onLinkCreate"
@note-commit="onNoteCommit" @note-commit="onNoteCommit"
@img-size="onImgSize" @img-size="onImgSize"
@viewport-rest="onViewportRest" @viewport-rest="onViewportRest"
@@ -672,11 +688,14 @@ const saveLabel = computed(() => ({
<aside class="cv-panel"> <aside class="cv-panel">
<div class="cv-panel__title">生成到画布</div> <div class="cv-panel__title">生成到画布</div>
<label class="cv-label">模型</label> <label class="cv-label">模型</label>
<select v-model="gen.modelId" class="cv-input" @change="gen.ratio = ratios[0]"> <select v-if="imageModels.length" v-model="gen.modelId" class="cv-input" @change="gen.ratio = ratios[0]">
<option v-for="m in imageModels" :key="m.id" :value="m.id"> <option v-for="m in imageModels" :key="m.id" :value="m.id">
{{ m.alias || m.name || m.id }} {{ m.alias || m.name || m.id }}
</option> </option>
</select> </select>
<div v-else class="cv-nomodels">
⚠ 站点还没有可用的图像模型。请管理员到「管理后台 → 模型管理」添加并启用模型后,这里就能选择了。
</div>
<label class="cv-label">比例</label> <label class="cv-label">比例</label>
<div class="cv-ratios"> <div class="cv-ratios">
@@ -708,8 +727,8 @@ 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">
双击笔记可编辑;Delete 删除选中;Ctrl+Z 撤销; 拖节点右侧圆点可连线(点击连线选中,Delete 删除);双击笔记编辑;
生成结果会自动连线到来源节点。 生成结果会自动连线到来源节点;Ctrl+Z 撤销。
</div> </div>
</aside> </aside>
</div> </div>
@@ -934,6 +953,14 @@ const saveLabel = computed(() => ({
color: #9aa1b2; color: #9aa1b2;
word-break: break-all; word-break: break-all;
} }
.cv-nomodels {
font-size: 11px;
color: #d8a657;
line-height: 1.6;
border: 1px dashed rgb(216 166 87 / 0.4);
border-radius: 8px;
padding: 8px 10px;
}
.cv-tips { .cv-tips {
font-size: 11px; font-size: 11px;
color: #5f6675; color: #5f6675;