fix(measurements): drag survives leaving the canvas

Drag was wired to canvas-level mousemove/mouseup, so any time the
cursor left the canvas (or a fast drag outpaced the canvas-bound event
firing) the drag died silently. Also onMouseLeave ended the drag, which
made off-edge nudges feel like they "didn't work".

Move the live mousemove/mouseup listeners to the window for the
duration of a press, leaving onMouseLeave to clear only the placement
preview. Window listeners are attached on the press and detached on
release (and on unmount, in case the user navigates away mid-drag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-25 10:13:09 +02:00
parent f1d32d0fb2
commit c6249aad5d

View File

@ -1299,6 +1299,20 @@ function onWheel(e: WheelEvent) {
redraw()
}
// Once a press starts on the canvas we listen at the window level so the
// drag survives the cursor leaving the canvas (or moving faster than the
// browser's canvas-bound event firing). Re-attached on each press, removed
// when the drag/pan ends.
function attachWindowDragListeners() {
window.addEventListener("mousemove", onWindowMouseMove)
window.addEventListener("mouseup", onWindowMouseUp)
}
function detachWindowDragListeners() {
window.removeEventListener("mousemove", onWindowMouseMove)
window.removeEventListener("mouseup", onWindowMouseUp)
}
function onMouseDown(e: MouseEvent) {
const { x, y } = getCanvasXY(e)
if (activeTool.value !== "none") {
@ -1311,34 +1325,53 @@ function onMouseDown(e: MouseEvent) {
isPanning = true
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
}
if (dragState || isPanning) attachWindowDragListeners()
}
function onMouseMove(e: MouseEvent) {
function onWindowMouseMove(e: MouseEvent) {
if (dragState) {
const { x, y } = getCanvasXY(e)
pointerMove(x, y)
return
}
if (isPanning) {
viewOffsetX.value = e.clientX - panStart.x
viewOffsetY.value = e.clientY - panStart.y
redraw()
}
}
function onWindowMouseUp() {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
function onMouseMove(e: MouseEvent) {
// While a drag/pan is in flight the window listener handles motion;
// here we only need the placement-preview cursor.
if (dragState || isPanning) return
if (activeTool.value !== "none") {
const { x, y } = getCanvasXY(e)
placementCursor.value = screenToImg(x, y)
drawOverlay()
return
}
if (!isPanning) return
viewOffsetX.value = e.clientX - panStart.x
viewOffsetY.value = e.clientY - panStart.y
redraw()
}
function onMouseUp() {
pointerUp()
isPanning = false
// Mouseup that lands inside the canvas covered by the window listener
// too, but we keep this so a quick click without movement still ends
// cleanly even if for some reason the window handler misses.
if (dragState || isPanning) {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
}
function onMouseLeave() {
pointerUp()
isPanning = false
// Don't end the drag here the window listener takes over while the
// cursor is outside the canvas. Just clear the placement preview.
placementCursor.value = null
drawOverlay()
}
@ -1601,6 +1634,7 @@ onMounted(() => {
onUnmounted(() => {
resizeObs?.disconnect()
window.removeEventListener("keydown", onKeyDown)
detachWindowDragListeners()
})
watch(() => props.imageUrl, loadImg)