fix(canvas): fix mobile touch interactions and desktop panning
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

- 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:
Samuel Prevost 2026-04-14 23:51:29 +02:00
parent f3d065e610
commit e72c4bc89b
2 changed files with 90 additions and 19 deletions

View File

@ -14,10 +14,13 @@ const scale = ref(1)
const offsetX = ref(0)
const offsetY = ref(0)
// Touch state for pinch-to-zoom
// Touch state for pinch-to-zoom and pan
let lastPinchDist = 0
let isPanning = false
let isPinching = false
let panStart = { x: 0, y: 0 }
// Track whether a Konva shape is being dragged (touch)
let isDraggingShape = false
const imageConfig = computed(() => {
const img = store.loadedImage
@ -31,6 +34,8 @@ const imageConfig = computed(() => {
}
})
const stageDraggable = ref(true)
const stageConfig = computed(() => ({
width: stageWidth.value,
height: stageHeight.value,
@ -38,7 +43,7 @@ const stageConfig = computed(() => ({
scaleY: scale.value,
x: offsetX.value,
y: offsetY.value,
draggable: false,
draggable: stageDraggable.value,
}))
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) {
if (e.touches.length === 2) {
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 t1 = e.touches[1]
if (t0 && t1) {
lastPinchDist = getTouchDistance(t0, t1)
}
} else if (e.touches.length === 1) {
// Single-finger pan (only if not on a point)
const target = e.target as HTMLElement
if (!target.closest(".konvajs-content")) return
// Record the touch but don't start panning yet
// give Konva a chance to claim it as a shape drag.
const t0 = e.touches[0]
if (!t0) return
isPanning = true
panStart = {
x: t0.clientX - offsetX.value,
y: t0.clientY - offsetY.value,
}
pendingPanTouch = { x: t0.clientX, y: t0.clientY }
isPanning = false
}
}
@ -249,17 +258,56 @@ function onTouchMove(e: TouchEvent) {
offsetY.value = cy - mousePointTo.y * newScale
lastPinchDist = dist
} else if (e.touches.length === 1 && isPanning) {
} else if (e.touches.length === 1 && !isDraggingShape) {
const t0 = e.touches[0]
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() {
lastPinchDist = 0
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
@ -308,13 +356,14 @@ watch(() => store.loadedImage, fitToCanvas)
<template>
<div
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"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<v-stage :config="stageConfig">
<v-stage :config="stageConfig" @dragend="onStageDragEnd">
<v-layer>
<!-- Background image -->
<v-image v-if="imageConfig" :config="imageConfig" />
@ -344,7 +393,9 @@ watch(() => store.loadedImage, fitToCanvas)
)"
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
:config="ptCfg"
@dragstart="onPointDragStart"
@dragmove="onPointDragMove"
@dragend="onPointDragEnd"
@click="onPointClick(datum.id)"
@tap="onPointClick(datum.id)"
/>

View File

@ -1,6 +1,26 @@
export async function hashFile(file: File): Promise<string> {
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("")
// Use file metadata as a fast, unique-enough key.
// crypto.subtle is unavailable on HTTP or some mobile browsers.
if (
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)}`
}