fix(measurements): grabbing existing handles wins over placement tools

Mirror DatumCanvas/Konva: hit-test on every mousedown/touchstart, and
let an existing-measurement hit win over both stage pan and placement
tools. Empty-space presses fall through — to a placement (if a tool is
active) or to a pan.

Suppress the trailing click event when the press picked up a prior
measurement so the placement tool doesn't commit a spurious new point
right after a corner drag ends.

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

View File

@ -1229,35 +1229,43 @@ function updateMeasurement(id: string, next: Measurement) {
measurements.value[idx] = next
}
function pointerDown(screenX: number, screenY: number): "measurement" | "pan" {
// Hit-test-first press handler, mirroring Konva's behaviour in the datum
// editor: a click on an existing shape always wins over the stage's own
// drag/pan, AND over any active placement tool. Returns "measurement" if
// we picked up an existing measurement (caller suppresses the trailing
// click so a placement tool doesn't commit a spurious point), "pan" if
// the press should start a stage pan, and "placement" if the press
// landed on empty space while a placement tool is active (caller does
// nothing the click event will commit the placement).
function pointerDown(
screenX: number,
screenY: number,
): "measurement" | "pan" | "placement" {
const cursor = { x: screenX, y: screenY }
const hit = hitTest(cursor)
if (!hit) {
// Empty-space click: deselect, fall through to pan behavior.
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
if (hit) {
selectedId.value = hit.measurementId
const target = measurements.value.find((m) => m.id === hit.measurementId)
if (target) {
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
dragState = {
mode,
measurementId: target.id,
handleKey: hit.handleKey,
startImg: screenToImg(screenX, screenY),
startSnapshot: cloneMeasurement(target),
}
}
return "pan"
}
selectedId.value = hit.measurementId
const target = measurements.value.find((m) => m.id === hit.measurementId)
if (!target) {
drawOverlay()
return "measurement"
}
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
dragState = {
mode,
measurementId: target.id,
handleKey: hit.handleKey,
startImg: screenToImg(screenX, screenY),
startSnapshot: cloneMeasurement(target),
// Empty-space press.
if (activeTool.value !== "none") return "placement"
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
}
drawOverlay()
return "measurement"
return "pan"
}
function pointerMove(screenX: number, screenY: number): boolean {
@ -1313,15 +1321,18 @@ function detachWindowDragListeners() {
window.removeEventListener("mouseup", onWindowMouseUp)
}
// True between an existing-measurement-grab on mousedown and the trailing
// click event the browser will fire. Suppresses the placement-tool click
// so grabbing a prior measurement doesn't commit a spurious new point.
let suppressNextClick = false
function onMouseDown(e: MouseEvent) {
const { x, y } = getCanvasXY(e)
if (activeTool.value !== "none") {
// Placement tools ignore mousedown; they commit on click so a user
// can drag-scroll accidentally without placing a spurious point.
return
}
suppressNextClick = false
const outcome = pointerDown(x, y)
if (outcome === "pan") {
if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "pan") {
isPanning = true
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
}
@ -1377,10 +1388,13 @@ function onMouseLeave() {
}
function onClick(e: MouseEvent) {
if (suppressNextClick) {
// Press picked up an existing measurement the trailing click
// is part of that gesture, not a placement.
suppressNextClick = false
return
}
if (activeTool.value === "none") return
// Guard against the click event that always follows a mousedown: if the
// user panned or dragged, that wasn't a placement click. Here we have no
// dragState at click-time because placements don't start one.
const { x, y } = getCanvasXY(e)
const imgPt = screenToImg(x, y)
handlePlacementClick(imgPt)
@ -1402,12 +1416,15 @@ function onTouchStart(e: TouchEvent) {
isPanning = false
} else if (e.touches.length === 1 && t0) {
const { x, y } = getCanvasXY(t0)
if (activeTool.value !== "none") {
placementCursor.value = screenToImg(x, y)
return
}
// Always hit-test first so a tap on an existing handle reshapes
// it even when a placement tool is active.
suppressNextClick = false
const outcome = pointerDown(x, y)
if (outcome === "pan") {
if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "placement") {
placementCursor.value = screenToImg(x, y)
} else if (outcome === "pan") {
isPanning = true
panStart = {
x: t0.clientX - viewOffsetX.value,