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:
@@ -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
|
||||
|
||||
@@ -551,6 +551,9 @@ const saveLabel = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 传送到 body:布局 <main> 自带 relative z-10 层叠上下文,留在里面会被
|
||||
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
|
||||
<Teleport to="body">
|
||||
<div class="cv-page">
|
||||
<!-- 顶栏 -->
|
||||
<div class="cv-top">
|
||||
@@ -676,6 +679,7 @@ const saveLabel = computed(() => ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -719,7 +723,8 @@ const saveLabel = computed(() => ({
|
||||
.cv-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
/* 站点顶栏是 z-40;画布全屏接管时必须盖住它 */
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0e1015;
|
||||
|
||||
Reference in New Issue
Block a user