feat(measurements): larger canvas, always-visible handles, fewer clicks

- Canvas fills the viewport (h-[calc(100vh-12rem)] on desktop, -14rem on
  mobile), matching the datum-editor precedent. The side panel tracks
  the same height so both read as equal-height siblings.
- Handles are now visible on every measurement, not just the selected
  one. Unselected: small (3px), low-alpha, faint white ring. Selected
  endpoints: 6.5px with a thick ring. Selected primary handles (ellipse
  center / angle vertex): 8px. The invisible grab radius is 14px so the
  tiny unselected dots are still easy to target.
- Selected handles keep their palette color (previously they went white
  along with the lines, so a selected handle disappeared on light
  backgrounds). Matches DatumCanvas's look.
- Hit-test priority is explicit: handles beat geometry, so a precision
  grab on an endpoint always wins over a line-body drag — including on
  an unselected measurement, which promotes to select-and-drag in a
  single gesture.
- Removed the on-canvas delete button next to the selected label. The
  side-panel row × and Delete/Backspace still work. HitResult.kind
  drops its "delete" variant; the matching draw + dispatch blocks and
  the dedicated hit region are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-24 18:22:56 +02:00
parent e07ee9d204
commit 497e71d63c

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue" import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import type { Point } from "@/types" import type { Point } from "@/types"
import { getDatumColor } from "@/lib/datums" import { getDatumColor } from "@/lib/datums"
@ -9,6 +10,15 @@ const props = defineProps<{
scalePxPerMm: number scalePxPerMm: number
}>() }>()
const isMobile = useMediaQuery("(max-width: 767px)")
// Mirror the datum-editor precedent: leave more vertical room for the mobile
// toolbar/chrome than desktop. Keep the canvas and the side list the same
// height so they align on desktop.
const canvasHeightClass = computed(() =>
isMobile.value ? "h-[calc(100vh-14rem)]" : "h-[calc(100vh-12rem)]",
)
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
const overlayRef = ref<HTMLCanvasElement | null>(null) const overlayRef = ref<HTMLCanvasElement | null>(null)
@ -311,8 +321,8 @@ function measurementTypeLabel(m: Measurement): string {
return "Angle" return "Angle"
} }
// Anchor point in image space where we place the label/delete button. Chosen // Anchor point in image space where we place the label. Chosen per type so
// per type so the label sits in a predictable, non-occluding position. // the label sits in a predictable, non-occluding position.
function labelAnchor(m: Measurement): Point { function labelAnchor(m: Measurement): Point {
if (m.type === "line") { if (m.type === "line") {
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 } return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
@ -345,28 +355,13 @@ function labelRect(
return { x, y, w, h, textX: anchor.x, textY: anchor.y - offsetY } return { x, y, w, h, textX: anchor.x, textY: anchor.y - offsetY }
} }
// Delete button sits immediately to the right of the label. Same screen-space
// rect is used for hit testing and for drawing.
function deleteButtonRect(
ctx: CanvasRenderingContext2D,
m: Measurement,
): { x: number; y: number; size: number } {
const rect = labelRect(ctx, m)
const size = 18
return {
x: rect.x + rect.w + 4,
y: rect.y + (rect.h - size) / 2,
size,
}
}
function drawMeasurement( function drawMeasurement(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
m: Measurement, m: Measurement,
isSelected: boolean, isSelected: boolean,
) { ) {
const baseColor = getDatumColor(m.colorIndex) const baseColor = getDatumColor(m.colorIndex)
const color = isSelected ? "#ffffff" : baseColor const strokeColor = isSelected ? "#ffffff" : baseColor
const lineAlpha = isSelected ? 1.0 : 0.8 const lineAlpha = isSelected ? 1.0 : 0.8
const lineWidth = isSelected ? 3 : 2 const lineWidth = isSelected ? 3 : 2
@ -374,11 +369,11 @@ function drawMeasurement(
ctx.globalAlpha = lineAlpha ctx.globalAlpha = lineAlpha
if (m.type === "line") { if (m.type === "line") {
drawLineGeometry(ctx, m, color, lineWidth, isSelected) drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} else if (m.type === "ellipse") { } else if (m.type === "ellipse") {
drawEllipseGeometry(ctx, m, color, lineWidth, isSelected) drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} else { } else {
drawAngleGeometry(ctx, m, color, lineWidth, isSelected) drawAngleGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} }
ctx.globalAlpha = 1.0 ctx.globalAlpha = 1.0
@ -389,7 +384,8 @@ function drawMeasurement(
function drawLineGeometry( function drawLineGeometry(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
m: LineMeasurement, m: LineMeasurement,
color: string, strokeColor: string,
handleColor: string,
lineWidth: number, lineWidth: number,
isSelected: boolean, isSelected: boolean,
) { ) {
@ -398,19 +394,20 @@ function drawLineGeometry(
ctx.beginPath() ctx.beginPath()
ctx.moveTo(sa.x, sa.y) ctx.moveTo(sa.x, sa.y)
ctx.lineTo(sb.x, sb.y) ctx.lineTo(sb.x, sb.y)
ctx.strokeStyle = color ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.setLineDash(isSelected ? [] : [6, 3]) ctx.setLineDash(isSelected ? [] : [6, 3])
ctx.stroke() ctx.stroke()
ctx.setLineDash([]) ctx.setLineDash([])
drawHandle(ctx, sa, color, isSelected) drawHandle(ctx, sa, handleColor, isSelected)
drawHandle(ctx, sb, color, isSelected) drawHandle(ctx, sb, handleColor, isSelected)
} }
function drawEllipseGeometry( function drawEllipseGeometry(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
m: EllipseMeasurement, m: EllipseMeasurement,
color: string, strokeColor: string,
handleColor: string,
lineWidth: number, lineWidth: number,
isSelected: boolean, isSelected: boolean,
) { ) {
@ -435,13 +432,12 @@ function drawEllipseGeometry(
if (i === 0) ctx.moveTo(x, y) if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y) else ctx.lineTo(x, y)
} }
ctx.strokeStyle = color ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.setLineDash(isSelected ? [] : [6, 3]) ctx.setLineDash(isSelected ? [] : [6, 3])
ctx.stroke() ctx.stroke()
ctx.setLineDash([]) ctx.setLineDash([])
// Axis guides
ctx.save() ctx.save()
ctx.globalAlpha *= 0.5 ctx.globalAlpha *= 0.5
ctx.beginPath() ctx.beginPath()
@ -449,20 +445,21 @@ function drawEllipseGeometry(
ctx.lineTo(a.x, a.y) ctx.lineTo(a.x, a.y)
ctx.moveTo(c.x, c.y) ctx.moveTo(c.x, c.y)
ctx.lineTo(b.x, b.y) ctx.lineTo(b.x, b.y)
ctx.strokeStyle = color ctx.strokeStyle = strokeColor
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.stroke() ctx.stroke()
ctx.restore() ctx.restore()
drawHandle(ctx, c, color, isSelected, true) drawHandle(ctx, c, handleColor, isSelected, true)
drawHandle(ctx, a, color, isSelected) drawHandle(ctx, a, handleColor, isSelected)
drawHandle(ctx, b, color, isSelected) drawHandle(ctx, b, handleColor, isSelected)
} }
function drawAngleGeometry( function drawAngleGeometry(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
m: AngleMeasurement, m: AngleMeasurement,
color: string, strokeColor: string,
handleColor: string,
lineWidth: number, lineWidth: number,
isSelected: boolean, isSelected: boolean,
) { ) {
@ -474,13 +471,12 @@ function drawAngleGeometry(
ctx.moveTo(a.x, a.y) ctx.moveTo(a.x, a.y)
ctx.lineTo(v.x, v.y) ctx.lineTo(v.x, v.y)
ctx.lineTo(b.x, b.y) ctx.lineTo(b.x, b.y)
ctx.strokeStyle = color ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.setLineDash(isSelected ? [] : [6, 3]) ctx.setLineDash(isSelected ? [] : [6, 3])
ctx.stroke() ctx.stroke()
ctx.setLineDash([]) ctx.setLineDash([])
// Arc sweep between the two arms to make the angle visually obvious.
const lenA = Math.hypot(a.x - v.x, a.y - v.y) const lenA = Math.hypot(a.x - v.x, a.y - v.y)
const lenB = Math.hypot(b.x - v.x, b.y - v.y) const lenB = Math.hypot(b.x - v.x, b.y - v.y)
const arcR = Math.max(16, Math.min(lenA, lenB) * 0.3) const arcR = Math.max(16, Math.min(lenA, lenB) * 0.3)
@ -496,17 +492,26 @@ function drawAngleGeometry(
ctx.globalAlpha *= 0.6 ctx.globalAlpha *= 0.6
ctx.beginPath() ctx.beginPath()
ctx.arc(v.x, v.y, arcR, thetaA, thetaA + delta, delta < 0) ctx.arc(v.x, v.y, arcR, thetaA, thetaA + delta, delta < 0)
ctx.strokeStyle = color ctx.strokeStyle = strokeColor
ctx.lineWidth = 1.5 ctx.lineWidth = 1.5
ctx.stroke() ctx.stroke()
ctx.restore() ctx.restore()
} }
drawHandle(ctx, v, color, isSelected, true) drawHandle(ctx, v, handleColor, isSelected, true)
drawHandle(ctx, a, color, isSelected) drawHandle(ctx, a, handleColor, isSelected)
drawHandle(ctx, b, color, isSelected) drawHandle(ctx, b, handleColor, isSelected)
} }
// Handle rendering follows the datum-editor precedent: a filled color center
// ringed in white. Size and alpha depend on the measurement's selection
// state so the user always sees where to grab, but unselected handles stay
// visually quiet.
// unselected: 3 px radius @ 0.5 alpha
// selected primary (center/vertex): 8 px radius, full alpha, thicker ring
// selected secondary: 6.5 px radius, full alpha
// The invisible hit region (HANDLE_HIT_PX) is wider than any of these so
// grabbing is forgiving even on tiny unselected dots.
function drawHandle( function drawHandle(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
s: Point, s: Point,
@ -514,14 +519,27 @@ function drawHandle(
isSelected: boolean, isSelected: boolean,
primary = false, primary = false,
) { ) {
const r = primary ? 6 : 5 ctx.save()
if (isSelected) {
const r = primary ? 8 : 6.5
ctx.beginPath() ctx.beginPath()
ctx.arc(s.x, s.y, r, 0, Math.PI * 2) ctx.arc(s.x, s.y, r, 0, Math.PI * 2)
ctx.fillStyle = color ctx.fillStyle = color
ctx.fill() ctx.fill()
ctx.strokeStyle = isSelected ? "#0b0b0b" : "#ffffff" ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1.5 ctx.lineWidth = 2
ctx.stroke() ctx.stroke()
} else {
ctx.globalAlpha = 0.5
ctx.beginPath()
ctx.arc(s.x, s.y, 3, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1
ctx.stroke()
}
ctx.restore()
} }
function drawLabel( function drawLabel(
@ -550,27 +568,6 @@ function drawLabel(
ctx.textAlign = "start" ctx.textAlign = "start"
ctx.textBaseline = "alphabetic" ctx.textBaseline = "alphabetic"
ctx.restore() ctx.restore()
if (isSelected) {
const btn = deleteButtonRect(ctx, m)
ctx.save()
ctx.fillStyle = "#ef4444"
roundRect(ctx, btn.x, btn.y, btn.size, btn.size, 4)
ctx.fill()
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1
ctx.stroke()
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1.75
ctx.beginPath()
const pad = 5
ctx.moveTo(btn.x + pad, btn.y + pad)
ctx.lineTo(btn.x + btn.size - pad, btn.y + btn.size - pad)
ctx.moveTo(btn.x + btn.size - pad, btn.y + pad)
ctx.lineTo(btn.x + pad, btn.y + btn.size - pad)
ctx.stroke()
ctx.restore()
}
} }
function roundRect( function roundRect(
@ -681,7 +678,9 @@ function getCanvasXY(e: MouseEvent | Touch): { x: number; y: number } {
// Hit-testing helpers. All thresholds are in screen pixels so they feel // Hit-testing helpers. All thresholds are in screen pixels so they feel
// consistent to the user regardless of zoom level. // consistent to the user regardless of zoom level.
const HANDLE_HIT_PX = 10 // Generous invisible hotspot around each handle so precision grabs feel
// forgiving on small unselected dots. Larger than any rendered handle.
const HANDLE_HIT_PX = 14
const LINE_HIT_PX = 6 const LINE_HIT_PX = 6
const ELLIPSE_HIT_PX = 7 const ELLIPSE_HIT_PX = 7
@ -733,8 +732,7 @@ interface HitResult {
// "handle" means the user grabbed a specific control point. // "handle" means the user grabbed a specific control point.
// "geometry" means they grabbed the line/curve/arms whole-measurement drag. // "geometry" means they grabbed the line/curve/arms whole-measurement drag.
// "label" means they clicked the label selection only (drag moves whole). // "label" means they clicked the label selection only (drag moves whole).
// "delete" means they clicked the delete X button. kind: "handle" | "geometry" | "label"
kind: "handle" | "geometry" | "label" | "delete"
handleKey: string | null handleKey: string | null
} }
@ -763,8 +761,8 @@ function hitTest(cursorScreen: Point): HitResult | null {
const ctx = overlayRef.value?.getContext("2d") const ctx = overlayRef.value?.getContext("2d")
if (!ctx) return null if (!ctx) return null
// Check the selected measurement first its label/delete button is // Check the selected measurement first its label is visually on top,
// visually on top, so its hit region should win ties. // so its hit region should win ties.
const ordered: Measurement[] = [] const ordered: Measurement[] = []
const sel = measurements.value.find((m) => m.id === selectedId.value) const sel = measurements.value.find((m) => m.id === selectedId.value)
if (sel) ordered.push(sel) if (sel) ordered.push(sel)
@ -772,21 +770,9 @@ function hitTest(cursorScreen: Point): HitResult | null {
if (m.id !== selectedId.value) ordered.push(m) if (m.id !== selectedId.value) ordered.push(m)
} }
// Priority 1: delete button on the selected measurement. // Priority 1: handles (selected first, so you can always grab the active
if (sel) { // measurement's handle even if it overlaps another). Handles beat geometry
const btn = deleteButtonRect(ctx, sel) // so precision grabs on endpoints always win over a line-body grab.
if (
cursorScreen.x >= btn.x &&
cursorScreen.x <= btn.x + btn.size &&
cursorScreen.y >= btn.y &&
cursorScreen.y <= btn.y + btn.size
) {
return { measurementId: sel.id, kind: "delete", handleKey: null }
}
}
// Priority 2: handles (selected first, so you can always grab the active
// measurement's handle even if it overlaps another).
for (const m of ordered) { for (const m of ordered) {
for (const h of getHandlePositions(m)) { for (const h of getHandlePositions(m)) {
const s = imgToScreen(h.pt) const s = imgToScreen(h.pt)
@ -796,7 +782,7 @@ function hitTest(cursorScreen: Point): HitResult | null {
} }
} }
// Priority 3: labels. // Priority 2: labels.
for (const m of ordered) { for (const m of ordered) {
const rect = labelRect(ctx, m) const rect = labelRect(ctx, m)
if ( if (
@ -809,7 +795,7 @@ function hitTest(cursorScreen: Point): HitResult | null {
} }
} }
// Priority 4: geometry bodies. // Priority 3: geometry bodies.
for (const m of ordered) { for (const m of ordered) {
if (m.type === "line") { if (m.type === "line") {
const sa = imgToScreen(m.a) const sa = imgToScreen(m.a)
@ -1040,11 +1026,6 @@ function pointerDown(screenX: number, screenY: number): "measurement" | "pan" {
return "pan" return "pan"
} }
if (hit.kind === "delete") {
deleteMeasurement(hit.measurementId)
return "measurement"
}
selectedId.value = hit.measurementId selectedId.value = hit.measurementId
const target = measurements.value.find((m) => m.id === hit.measurementId) const target = measurements.value.find((m) => m.id === hit.measurementId)
if (!target) { if (!target) {
@ -1547,12 +1528,17 @@ watch(() => props.scalePxPerMm, () => { drawOverlay() })
</span> </span>
</div> </div>
<!-- Canvas + side list --> <!-- Canvas + side list. The parent ResultViewer clamps width to
max-w-4xl; widening the canvas beyond that requires a parent
change (see ResultViewer.vue root container). -->
<div class="grid gap-3 md:grid-cols-[1fr_220px]"> <div class="grid gap-3 md:grid-cols-[1fr_220px]">
<div <div
ref="containerRef" ref="containerRef"
class="relative h-[500px] overflow-hidden rounded-lg border border-border bg-muted" class="relative overflow-hidden rounded-lg border border-border bg-muted"
:class="activeTool !== 'none' ? 'cursor-crosshair' : 'cursor-grab'" :class="[
canvasHeightClass,
activeTool !== 'none' ? 'cursor-crosshair' : 'cursor-grab',
]"
> >
<canvas <canvas
ref="canvasRef" ref="canvasRef"
@ -1576,7 +1562,8 @@ watch(() => props.scalePxPerMm, () => { drawOverlay() })
<!-- Measurement list --> <!-- Measurement list -->
<div <div
class="flex max-h-[500px] flex-col gap-1 overflow-y-auto rounded-lg border border-border bg-muted/30 p-2" class="flex flex-col gap-1 overflow-y-auto rounded-lg border border-border bg-muted/30 p-2"
:class="canvasHeightClass"
> >
<div <div
v-if="measurementSummaries.length === 0" v-if="measurementSummaries.length === 0"