feat(measurements): rect click-through, color-as-selection, fullscreen, label clamping
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

- Rectangles now follow the same body-click rule as circles: corner dots
  drag, body (rim + interior) selects when no tool is active and falls
  through to placement when one is. Lets the user draw measurements
  inside an existing rectangle.
- Selected shapes keep their own colour for the geometry stroke instead
  of swapping to white. Unselected shapes are dimmed to 0.45 alpha so
  the selected one still stands out, and the colour stays consistent
  with the label pill — easier to pair shape ↔ label.
- Label clearance: anchors that coincide with a handle (ellipse, circle,
  angle) now use a larger Y offset so the pill clears the handle disk.
  Inter-label gap bumped from 4 to 8 px.
- Export labels are clamped to the destination canvas after collision
  resolution so full-resolution exports never clip a pill off the edge.
- Fullscreen toggle in the toolbar (icon + text). Esc exits. Renders the
  viewer as a fixed-position overlay; the existing ResizeObserver picks
  up the new container and re-fits the image automatically.
This commit is contained in:
Samuel Prevost 2026-04-30 23:34:24 +02:00
parent 8c7f4078df
commit 590ba16596

View File

@ -51,6 +51,13 @@ const gridSpacingMm = ref(10)
// the nearest 45° (0/45/90/135) relative to the fixed endpoint or angle // the nearest 45° (0/45/90/135) relative to the fixed endpoint or angle
// vertex. Length is preserved; only direction is constrained. // vertex. Length is preserved; only direction is constrained.
const snapToAngle = ref(false) const snapToAngle = ref(false)
// Full-screen mode: render the viewer as a fixed-position overlay covering
// the viewport. The ResizeObserver picks up the new container size and
// re-fits the image; nothing else needs to change.
const isFullscreen = ref(false)
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// Measurement types live in `@/types/measurements` so the cache module and // Measurement types live in `@/types/measurements` so the cache module and
// other consumers can share them. Geometry is in image space so it stays // other consumers can share them. Geometry is in image space so it stays
@ -558,7 +565,16 @@ function labelRect(
const h = 20 * rt.strokeMul const h = 20 * rt.strokeMul
const w = tw + pad * 2 const w = tw + pad * 2
// Offset the label above the anchor so it doesn't sit on top of a handle. // Offset the label above the anchor so it doesn't sit on top of a handle.
const offsetY = (m.type === "angle" ? 22 : 14) * rt.strokeMul // Anchor types whose anchor coincides with a handle disk need extra
// clearance so the label doesn't sit on top of the handle. Selected
// primary handles are 8 px radius + 2 px stroke, so we need at least
// h/2 + 10 + breathing-room. Line midpoint and rect centroid don't
// host a handle they only need a small visual gap from the anchor.
const anchorOnHandle =
m.type === "ellipse" ||
m.type === "circle" ||
m.type === "angle"
const offsetY = (anchorOnHandle ? 26 : 16) * rt.strokeMul
const x = anchor.x - w / 2 const x = anchor.x - w / 2
const y = anchor.y - h / 2 - offsetY const y = anchor.y - h / 2 - offsetY
return { x, y, w, h, textX: anchor.x, textY: anchor.y - offsetY, fontPx } return { x, y, w, h, textX: anchor.x, textY: anchor.y - offsetY, fontPx }
@ -572,8 +588,14 @@ function drawMeasurement(
) { ) {
const baseColor = getDatumColor(m.colorIndex) const baseColor = getDatumColor(m.colorIndex)
const decorate = rt.drawSelectionDecorations const decorate = rt.drawSelectionDecorations
const strokeColor = decorate && isSelected ? "#ffffff" : baseColor // The selected shape always renders in its own colour rather than a
const lineAlpha = decorate ? (isSelected ? 1.0 : 0.8) : 1.0 // white "highlight" stroke the colour is what ties the geometry to
// its label pill, so swapping it out on selection actively hurts
// identification. We dim unselected shapes more aggressively (0.45 vs
// 0.8) and keep the thicker, solid stroke for the selected one so it
// still stands out without recolouring.
const strokeColor = baseColor
const lineAlpha = decorate ? (isSelected ? 1.0 : 0.45) : 1.0
const lineWidth = (decorate && isSelected ? 3 : 2) * rt.strokeMul const lineWidth = (decorate && isSelected ? 3 : 2) * rt.strokeMul
ctx.save() ctx.save()
@ -893,7 +915,7 @@ function resolveLabelPositions(
list: Measurement[], list: Measurement[],
rt: RenderCtx, rt: RenderCtx,
): Map<string, LabelPos> { ): Map<string, LabelPos> {
const gap = 4 * rt.strokeMul const gap = 8 * rt.strokeMul
const items = list.map((m) => { const items = list.map((m) => {
const anchor = imgToCtx(labelAnchor(m), rt) const anchor = imgToCtx(labelAnchor(m), rt)
const rect = labelRect(ctx, m, rt) const rect = labelRect(ctx, m, rt)
@ -923,19 +945,36 @@ function resolveLabelPositions(
placed.push(it) placed.push(it)
} }
// Clamp every label to the destination canvas so exports never clip
// labels off the edge. Done after collision resolution as a separate
// pass clamping can re-introduce overlaps, which we accept since
// staying inside the bitmap is more important than zero-overlap on
// crowded edges. The live overlay benefits too: labels near the
// viewport edge stay visible instead of running off the canvas.
const cw = ctx.canvas.width
const ch = ctx.canvas.height
const out = new Map<string, LabelPos>() const out = new Map<string, LabelPos>()
const shiftThreshold = 4 * rt.strokeMul const shiftThreshold = 4 * rt.strokeMul
for (const it of placed) { for (const it of placed) {
let { x, y, w, h, textX, textY, fontPx } = it.rect
const dx =
x < 0 ? -x : x + w > cw ? cw - (x + w) : 0
const dy =
y < 0 ? -y : y + h > ch ? ch - (y + h) : 0
x += dx
y += dy
textX += dx
textY += dy
out.set(it.id, { out.set(it.id, {
x: it.rect.x, x,
y: it.rect.y, y,
w: it.rect.w, w,
h: it.rect.h, h,
textX: it.rect.textX, textX,
textY: it.rect.textY, textY,
fontPx: it.rect.fontPx, fontPx,
anchor: it.anchor, anchor: it.anchor,
shifted: Math.abs(it.rect.y - it.originalY) > shiftThreshold, shifted: Math.abs(y - it.originalY) > shiftThreshold,
}) })
} }
return out return out
@ -1708,21 +1747,20 @@ function pointerDown(
const hit = hitTest(cursor) const hit = hitTest(cursor)
if (hit) { if (hit) {
const target = measurements.value.find((m) => m.id === hit.measurementId) const target = measurements.value.find((m) => m.id === hit.measurementId)
// Circles are exclusively manipulated via their two handles // Closed shapes (circle, rectangle) are only draggable from their
// center (which translates the whole circle while keeping its // handles corner dots for rect, center / edge for circle. Body
// radius) and edge (resize). Body and label hits don't drag. // hits select but never start a drag, so big shapes don't drift
const isCircleBodyHit = // when the user clicks inside them. When a placement tool is
target?.type === "circle" && hit.kind !== "handle" // active, body hits fall through entirely so the user can draw a
// When a placement tool is active and the hit is on a circle's // new measurement on top of an existing closed shape.
// body, fall through to placement entirely don't select, don't const isClosedBodyHit =
// suppress the click. This lets the user draw a new measurement (target?.type === "circle" || target?.type === "rectangle") &&
// on top of an existing circle. Handles still claim the press hit.kind !== "handle"
// so the circle can be reshaped from within an active tool too. if (isClosedBodyHit && activeTool.value !== "none") {
if (isCircleBodyHit && activeTool.value !== "none") {
return "placement" return "placement"
} }
selectedId.value = hit.measurementId selectedId.value = hit.measurementId
if (target && !isCircleBodyHit) { if (target && !isClosedBodyHit) {
const mode: DragMode = hit.kind === "handle" ? "handle" : "move" const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
dragState = { dragState = {
mode, mode,
@ -1978,6 +2016,10 @@ function onKeyDown(e: KeyboardEvent) {
if (selectedId.value !== null) { if (selectedId.value !== null) {
selectedId.value = null selectedId.value = null
drawOverlay() drawOverlay()
return
}
if (isFullscreen.value) {
isFullscreen.value = false
} }
return return
} }
@ -2306,7 +2348,16 @@ watch([viewScale, viewOffsetX, viewOffsetY], () => {
</script> </script>
<template> <template>
<div class="space-y-3"> <!-- In fullscreen mode the viewer becomes a fixed-position overlay that
covers the viewport. The ResizeObserver inside the canvas picks up
the new container size and re-fits automatically. Esc exits. -->
<div
:class="
isFullscreen
? 'fixed inset-0 z-50 space-y-3 overflow-auto bg-background p-4'
: 'space-y-3'
"
>
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 text-sm"> <div class="flex flex-wrap items-center gap-2 text-sm">
<div class="inline-flex rounded-md border border-border p-0.5"> <div class="inline-flex rounded-md border border-border p-0.5">
@ -2443,6 +2494,52 @@ watch([viewScale, viewOffsetX, viewOffsetY], () => {
Clear all Clear all
</button> </button>
<button
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground"
:title="
isFullscreen
? 'Exit fullscreen (Esc)'
: 'Expand to fullscreen'
"
@click="toggleFullscreen"
>
<svg
v-if="!isFullscreen"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9V3h6" />
<path d="M21 9V3h-6" />
<path d="M3 15v6h6" />
<path d="M21 15v6h-6" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 3v6H3" />
<path d="M15 3v6h6" />
<path d="M9 21v-6H3" />
<path d="M15 21v-6h6" />
</svg>
{{ isFullscreen ? "Exit" : "Fullscreen" }}
</button>
<div class="mx-1 h-4 w-px bg-border" /> <div class="mx-1 h-4 w-px bg-border" />
<label <label