diff --git a/frontend/src/components/canvas/CanvasNodeView.vue b/frontend/src/components/canvas/CanvasNodeView.vue index 860d0d5..a59d6fa 100644 --- a/frontend/src/components/canvas/CanvasNodeView.vue +++ b/frontend/src/components/canvas/CanvasNodeView.vue @@ -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" diff --git a/frontend/src/components/canvas/CanvasStage.vue b/frontend/src/components/canvas/CanvasStage.vue index f34782b..9fdb36c 100644 --- a/frontend/src/components/canvas/CanvasStage.vue +++ b/frontend/src/components/canvas/CanvasStage.vue @@ -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 diff --git a/frontend/src/views/CanvasView.vue b/frontend/src/views/CanvasView.vue index 5d2f95a..8429817 100644 --- a/frontend/src/views/CanvasView.vue +++ b/frontend/src/views/CanvasView.vue @@ -551,7 +551,10 @@ const saveLabel = computed(() => ({