fix(canvas): E2E-hardened interaction fixes

- Teleport canvas to body: layout <main> z-10 stacking context let the
  fixed site header (z-40) cover the canvas toolbar
- defer setPointerCapture until the 3px drag threshold: immediate capture
  retargets click/dblclick to the stage, which silently broke note editing
- commitNote no longer early-returns on the editing flag (event-ordering
  could drop typed text); sync node.text back to the non-controlled
  contenteditable on undo/redo restores
- contenteditable only while editing: single click used to focus and allow
  raw typing outside edit mode, swallowing Delete-key handling
This commit is contained in:
dokploy-bot
2026-09-19 22:55:39 +08:00
parent 571ee2c801
commit e83da82f3d
3 changed files with 30 additions and 9 deletions
@@ -2,7 +2,7 @@
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { thumbUrl } from '../../api'
const props = defineProps({
@@ -20,6 +20,17 @@ onMounted(() => {
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
})
// 撤销/重做会整体替换 nodes 数组但复用组件(key 不变),DOM 文字会变成
// 旧值 —— 非编辑状态下把 node.text 同步回 DOM。
watch(
() => props.node.text,
(t) => {
if (props.node.type !== 'note' || editing.value || !noteEl.value) return
const want = t || ''
if (noteEl.value.innerText !== want) noteEl.value.innerText = want
},
)
function onImgLoad(e) {
imgLoaded.value = true
const img = e.target
@@ -54,9 +65,12 @@ function startEdit() {
}
function commitNote() {
if (!editing.value) return
const el = noteEl.value
editing.value = false
const text = (noteEl.value?.innerText || '').slice(0, 4000)
if (!el) return
// 不依赖 editing 标志做早退:焦点/事件时序可能把标志先置 false,
// 但用户输入的文字仍然必须被提交(否则静默丢字)。
const text = (el.innerText || '').replace(/\u00a0/g, ' ').slice(0, 4000)
if (text !== (props.node.text || '')) emit('note-commit', text)
}
@@ -105,7 +119,7 @@ defineExpose({ startEdit, commitNote })
v-else-if="node.type === 'note'"
ref="noteEl"
class="cv-node__note"
contenteditable="true"
:contenteditable="editing"
spellcheck="false"
@blur="commitNote"
@keydown.enter.exact.prevent="commitNote"
@@ -155,12 +155,13 @@ function onPointerDown(e) {
.filter(Boolean)
.map((n) => ({ n, x: n.x, y: n.y }))
const start = screenToWorld(p.x, p.y, vp)
act = { mode: 'drag', start, origins, moved: false, px: p.x, py: p.y }
// 不立即 setPointerCapture:捕获会把后续 click/dblclick 的目标重定向到
// 舞台,双击编辑等点击语义就全丢了。拖过阈值后再捕获。
act = { mode: 'drag', start, origins, moved: false, px: p.x, py: p.y, pid: e.pointerId }
} else {
act = { mode: 'marquee', px: p.x, py: p.y, base: new Set(selection.value) }
marquee.value = { x: p.x, y: p.y, w: 0, h: 0 }
}
stageEl.value.setPointerCapture(e.pointerId)
}
let rafPending = false
@@ -189,6 +190,7 @@ function applyMove(e) {
if (!act.moved) {
if (Math.hypot(p.x - act.px, p.y - act.py) < 3) return
act.moved = true
try { stageEl.value.setPointerCapture(act.pid) } catch { /* 已释放 */ }
}
const cur = screenToWorld(p.x, p.y, vp)
const dx = cur.x - act.start.x