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 统一缩放, // 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
// 这里只负责节点自身内容:image / note / gen(生成中占位)。 // 这里只负责节点自身内容:image / note / gen(生成中占位)。
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。 // 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
import { ref, onMounted } from 'vue' import { ref, onMounted, watch } from 'vue'
import { thumbUrl } from '../../api' import { thumbUrl } from '../../api'
const props = defineProps({ const props = defineProps({
@@ -20,6 +20,17 @@ onMounted(() => {
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || '' 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) { function onImgLoad(e) {
imgLoaded.value = true imgLoaded.value = true
const img = e.target const img = e.target
@@ -54,9 +65,12 @@ function startEdit() {
} }
function commitNote() { function commitNote() {
if (!editing.value) return const el = noteEl.value
editing.value = false 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) if (text !== (props.node.text || '')) emit('note-commit', text)
} }
@@ -105,7 +119,7 @@ defineExpose({ startEdit, commitNote })
v-else-if="node.type === 'note'" v-else-if="node.type === 'note'"
ref="noteEl" ref="noteEl"
class="cv-node__note" class="cv-node__note"
contenteditable="true" :contenteditable="editing"
spellcheck="false" spellcheck="false"
@blur="commitNote" @blur="commitNote"
@keydown.enter.exact.prevent="commitNote" @keydown.enter.exact.prevent="commitNote"
@@ -155,12 +155,13 @@ function onPointerDown(e) {
.filter(Boolean) .filter(Boolean)
.map((n) => ({ n, x: n.x, y: n.y })) .map((n) => ({ n, x: n.x, y: n.y }))
const start = screenToWorld(p.x, p.y, vp) 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 { } else {
act = { mode: 'marquee', px: p.x, py: p.y, base: new Set(selection.value) } 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 } marquee.value = { x: p.x, y: p.y, w: 0, h: 0 }
} }
stageEl.value.setPointerCapture(e.pointerId)
} }
let rafPending = false let rafPending = false
@@ -189,6 +190,7 @@ function applyMove(e) {
if (!act.moved) { if (!act.moved) {
if (Math.hypot(p.x - act.px, p.y - act.py) < 3) return if (Math.hypot(p.x - act.px, p.y - act.py) < 3) return
act.moved = true act.moved = true
try { stageEl.value.setPointerCapture(act.pid) } catch { /* 已释放 */ }
} }
const cur = screenToWorld(p.x, p.y, vp) const cur = screenToWorld(p.x, p.y, vp)
const dx = cur.x - act.start.x const dx = cur.x - act.start.x
+8 -3
View File
@@ -551,7 +551,10 @@ const saveLabel = computed(() => ({
</script> </script>
<template> <template>
<div class="cv-page"> <!-- 传送到 body:布局 <main> 自带 relative z-10 层叠上下文,留在里面会被
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
<Teleport to="body">
<div class="cv-page">
<!-- 顶栏 --> <!-- 顶栏 -->
<div class="cv-top"> <div class="cv-top">
<div class="cv-top__left"> <div class="cv-top__left">
@@ -675,7 +678,8 @@ const saveLabel = computed(() => ({
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -719,7 +723,8 @@ const saveLabel = computed(() => ({
.cv-page { .cv-page {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 40; /* 站点顶栏是 z-40;画布全屏接管时必须盖住它 */
z-index: 50;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0e1015; background: #0e1015;