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 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 cursor = { x: screenX, y: screenY }
const hit = hitTest(cursor) const hit = hitTest(cursor)
if (!hit) { if (hit) {
// Empty-space click: deselect, fall through to pan behavior. selectedId.value = hit.measurementId
if (selectedId.value !== null) { const target = measurements.value.find((m) => m.id === hit.measurementId)
selectedId.value = null if (target) {
drawOverlay() 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() drawOverlay()
return "measurement" return "measurement"
} }
// Empty-space press.
const mode: DragMode = hit.kind === "handle" ? "handle" : "move" if (activeTool.value !== "none") return "placement"
dragState = { if (selectedId.value !== null) {
mode, selectedId.value = null
measurementId: target.id, drawOverlay()
handleKey: hit.handleKey,
startImg: screenToImg(screenX, screenY),
startSnapshot: cloneMeasurement(target),
} }
drawOverlay() return "pan"
return "measurement"
} }
function pointerMove(screenX: number, screenY: number): boolean { function pointerMove(screenX: number, screenY: number): boolean {
@ -1313,15 +1321,18 @@ function detachWindowDragListeners() {
window.removeEventListener("mouseup", onWindowMouseUp) 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) { function onMouseDown(e: MouseEvent) {
const { x, y } = getCanvasXY(e) const { x, y } = getCanvasXY(e)
if (activeTool.value !== "none") { suppressNextClick = false
// Placement tools ignore mousedown; they commit on click so a user
// can drag-scroll accidentally without placing a spurious point.
return
}
const outcome = pointerDown(x, y) const outcome = pointerDown(x, y)
if (outcome === "pan") { if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "pan") {
isPanning = true isPanning = true
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value } panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
} }
@ -1377,10 +1388,13 @@ function onMouseLeave() {
} }
function onClick(e: MouseEvent) { 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 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 { x, y } = getCanvasXY(e)
const imgPt = screenToImg(x, y) const imgPt = screenToImg(x, y)
handlePlacementClick(imgPt) handlePlacementClick(imgPt)
@ -1402,12 +1416,15 @@ function onTouchStart(e: TouchEvent) {
isPanning = false isPanning = false
} else if (e.touches.length === 1 && t0) { } else if (e.touches.length === 1 && t0) {
const { x, y } = getCanvasXY(t0) const { x, y } = getCanvasXY(t0)
if (activeTool.value !== "none") { // Always hit-test first so a tap on an existing handle reshapes
placementCursor.value = screenToImg(x, y) // it even when a placement tool is active.
return suppressNextClick = false
}
const outcome = pointerDown(x, y) 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 isPanning = true
panStart = { panStart = {
x: t0.clientX - viewOffsetX.value, x: t0.clientX - viewOffsetX.value,