fix(canvas): fix mobile touch interactions and desktop panning
- Mobile: fix pinch-zoom jitter by disabling Konva stage drag during two-finger gestures so only our zoom handler controls position - Mobile: fix dot dragging — defer pan start so Konva can claim touch for shape drag first (isDraggingShape flag) - Desktop: enable stage drag for click-drag panning, disable during point drag; filter dragend by nodeType to prevent offset corruption - Fallback file hash using name+size+lastModified when crypto.subtle is unavailable (HTTP contexts, some mobile browsers) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f3d065e610
commit
e72c4bc89b
@ -14,10 +14,13 @@ const scale = ref(1)
|
|||||||
const offsetX = ref(0)
|
const offsetX = ref(0)
|
||||||
const offsetY = ref(0)
|
const offsetY = ref(0)
|
||||||
|
|
||||||
// Touch state for pinch-to-zoom
|
// Touch state for pinch-to-zoom and pan
|
||||||
let lastPinchDist = 0
|
let lastPinchDist = 0
|
||||||
let isPanning = false
|
let isPanning = false
|
||||||
|
let isPinching = false
|
||||||
let panStart = { x: 0, y: 0 }
|
let panStart = { x: 0, y: 0 }
|
||||||
|
// Track whether a Konva shape is being dragged (touch)
|
||||||
|
let isDraggingShape = false
|
||||||
|
|
||||||
const imageConfig = computed(() => {
|
const imageConfig = computed(() => {
|
||||||
const img = store.loadedImage
|
const img = store.loadedImage
|
||||||
@ -31,6 +34,8 @@ const imageConfig = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const stageDraggable = ref(true)
|
||||||
|
|
||||||
const stageConfig = computed(() => ({
|
const stageConfig = computed(() => ({
|
||||||
width: stageWidth.value,
|
width: stageWidth.value,
|
||||||
height: stageHeight.value,
|
height: stageHeight.value,
|
||||||
@ -38,7 +43,7 @@ const stageConfig = computed(() => ({
|
|||||||
scaleY: scale.value,
|
scaleY: scale.value,
|
||||||
x: offsetX.value,
|
x: offsetX.value,
|
||||||
y: offsetY.value,
|
y: offsetY.value,
|
||||||
draggable: false,
|
draggable: stageDraggable.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function datumIndex(datum: Datum): number {
|
function datumIndex(datum: Datum): number {
|
||||||
@ -198,25 +203,29 @@ function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingPanTouch: { x: number; y: number } | null = null
|
||||||
|
|
||||||
function onTouchStart(e: TouchEvent) {
|
function onTouchStart(e: TouchEvent) {
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
isPanning = false
|
||||||
|
isDraggingShape = false
|
||||||
|
isPinching = true
|
||||||
|
pendingPanTouch = null
|
||||||
|
// Disable stage drag so Konva doesn't fight with pinch-zoom
|
||||||
|
stageDraggable.value = false
|
||||||
const t0 = e.touches[0]
|
const t0 = e.touches[0]
|
||||||
const t1 = e.touches[1]
|
const t1 = e.touches[1]
|
||||||
if (t0 && t1) {
|
if (t0 && t1) {
|
||||||
lastPinchDist = getTouchDistance(t0, t1)
|
lastPinchDist = getTouchDistance(t0, t1)
|
||||||
}
|
}
|
||||||
} else if (e.touches.length === 1) {
|
} else if (e.touches.length === 1) {
|
||||||
// Single-finger pan (only if not on a point)
|
// Record the touch but don't start panning yet —
|
||||||
const target = e.target as HTMLElement
|
// give Konva a chance to claim it as a shape drag.
|
||||||
if (!target.closest(".konvajs-content")) return
|
|
||||||
const t0 = e.touches[0]
|
const t0 = e.touches[0]
|
||||||
if (!t0) return
|
if (!t0) return
|
||||||
isPanning = true
|
pendingPanTouch = { x: t0.clientX, y: t0.clientY }
|
||||||
panStart = {
|
isPanning = false
|
||||||
x: t0.clientX - offsetX.value,
|
|
||||||
y: t0.clientY - offsetY.value,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,17 +258,56 @@ function onTouchMove(e: TouchEvent) {
|
|||||||
offsetY.value = cy - mousePointTo.y * newScale
|
offsetY.value = cy - mousePointTo.y * newScale
|
||||||
|
|
||||||
lastPinchDist = dist
|
lastPinchDist = dist
|
||||||
} else if (e.touches.length === 1 && isPanning) {
|
} else if (e.touches.length === 1 && !isDraggingShape) {
|
||||||
const t0 = e.touches[0]
|
const t0 = e.touches[0]
|
||||||
if (!t0) return
|
if (!t0) return
|
||||||
offsetX.value = t0.clientX - panStart.x
|
|
||||||
offsetY.value = t0.clientY - panStart.y
|
// If we haven't started panning yet, promote the pending touch
|
||||||
|
if (!isPanning && pendingPanTouch) {
|
||||||
|
isPanning = true
|
||||||
|
panStart = {
|
||||||
|
x: pendingPanTouch.x - offsetX.value,
|
||||||
|
y: pendingPanTouch.y - offsetY.value,
|
||||||
|
}
|
||||||
|
pendingPanTouch = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPanning) {
|
||||||
|
offsetX.value = t0.clientX - panStart.x
|
||||||
|
offsetY.value = t0.clientY - panStart.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() {
|
||||||
lastPinchDist = 0
|
lastPinchDist = 0
|
||||||
isPanning = false
|
isPanning = false
|
||||||
|
isDraggingShape = false
|
||||||
|
pendingPanTouch = null
|
||||||
|
if (isPinching) {
|
||||||
|
isPinching = false
|
||||||
|
// Re-enable stage drag after pinch ends
|
||||||
|
stageDraggable.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointDragStart() {
|
||||||
|
isDraggingShape = true
|
||||||
|
isPanning = false
|
||||||
|
pendingPanTouch = null
|
||||||
|
stageDraggable.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointDragEnd() {
|
||||||
|
isDraggingShape = false
|
||||||
|
stageDraggable.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStageDragEnd(e: { target: { x: () => number; y: () => number; nodeType: string } }) {
|
||||||
|
// Only sync offset when the stage itself was dragged, not a child shape
|
||||||
|
if (e.target.nodeType !== "Stage") return
|
||||||
|
offsetX.value = e.target.x()
|
||||||
|
offsetY.value = e.target.y()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fit image to canvas on mount
|
// Fit image to canvas on mount
|
||||||
@ -308,13 +356,14 @@ watch(() => store.loadedImage, fitToCanvas)
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="h-full w-full touch-none overflow-hidden rounded-lg border border-border bg-muted"
|
class="h-full w-full cursor-grab overflow-hidden rounded-lg border border-border bg-muted active:cursor-grabbing"
|
||||||
|
style="touch-action: none"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
@touchstart="onTouchStart"
|
@touchstart="onTouchStart"
|
||||||
@touchmove="onTouchMove"
|
@touchmove="onTouchMove"
|
||||||
@touchend="onTouchEnd"
|
@touchend="onTouchEnd"
|
||||||
>
|
>
|
||||||
<v-stage :config="stageConfig">
|
<v-stage :config="stageConfig" @dragend="onStageDragEnd">
|
||||||
<v-layer>
|
<v-layer>
|
||||||
<!-- Background image -->
|
<!-- Background image -->
|
||||||
<v-image v-if="imageConfig" :config="imageConfig" />
|
<v-image v-if="imageConfig" :config="imageConfig" />
|
||||||
@ -344,7 +393,9 @@ watch(() => store.loadedImage, fitToCanvas)
|
|||||||
)"
|
)"
|
||||||
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
||||||
:config="ptCfg"
|
:config="ptCfg"
|
||||||
|
@dragstart="onPointDragStart"
|
||||||
@dragmove="onPointDragMove"
|
@dragmove="onPointDragMove"
|
||||||
|
@dragend="onPointDragEnd"
|
||||||
@click="onPointClick(datum.id)"
|
@click="onPointClick(datum.id)"
|
||||||
@tap="onPointClick(datum.id)"
|
@tap="onPointClick(datum.id)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,26 @@
|
|||||||
export async function hashFile(file: File): Promise<string> {
|
export async function hashFile(file: File): Promise<string> {
|
||||||
const buffer = await file.arrayBuffer()
|
// Use file metadata as a fast, unique-enough key.
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
|
// crypto.subtle is unavailable on HTTP or some mobile browsers.
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
if (
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
typeof crypto !== "undefined" &&
|
||||||
|
crypto.subtle &&
|
||||||
|
typeof crypto.subtle.digest === "function"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const hashBuffer = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
buffer,
|
||||||
|
)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
return hashArray
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
} catch {
|
||||||
|
// Fall through to metadata-based hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: name + size + lastModified
|
||||||
|
return `${file.name}-${String(file.size)}-${String(file.lastModified)}`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user