From 590ba16596ab65d743499d36528887af752543d3 Mon Sep 17 00:00:00 2001 From: Samuel Prevost Date: Thu, 30 Apr 2026 23:34:24 +0200 Subject: [PATCH] feat(measurements): rect click-through, color-as-selection, fullscreen, label clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/CorrectedImageViewer.vue | 147 ++++++++++++++++++++---- 1 file changed, 122 insertions(+), 25 deletions(-) diff --git a/src/components/CorrectedImageViewer.vue b/src/components/CorrectedImageViewer.vue index eaebca4..2f1dd52 100644 --- a/src/components/CorrectedImageViewer.vue +++ b/src/components/CorrectedImageViewer.vue @@ -51,6 +51,13 @@ const gridSpacingMm = ref(10) // the nearest 45° (0/45/90/135…) relative to the fixed endpoint or angle // vertex. Length is preserved; only direction is constrained. 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 // 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 w = tw + pad * 2 // 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 y = anchor.y - h / 2 - offsetY 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 decorate = rt.drawSelectionDecorations - const strokeColor = decorate && isSelected ? "#ffffff" : baseColor - const lineAlpha = decorate ? (isSelected ? 1.0 : 0.8) : 1.0 + // The selected shape always renders in its own colour rather than a + // 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 ctx.save() @@ -893,7 +915,7 @@ function resolveLabelPositions( list: Measurement[], rt: RenderCtx, ): Map { - const gap = 4 * rt.strokeMul + const gap = 8 * rt.strokeMul const items = list.map((m) => { const anchor = imgToCtx(labelAnchor(m), rt) const rect = labelRect(ctx, m, rt) @@ -923,19 +945,36 @@ function resolveLabelPositions( 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() const shiftThreshold = 4 * rt.strokeMul 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, { - x: it.rect.x, - y: it.rect.y, - w: it.rect.w, - h: it.rect.h, - textX: it.rect.textX, - textY: it.rect.textY, - fontPx: it.rect.fontPx, + x, + y, + w, + h, + textX, + textY, + fontPx, anchor: it.anchor, - shifted: Math.abs(it.rect.y - it.originalY) > shiftThreshold, + shifted: Math.abs(y - it.originalY) > shiftThreshold, }) } return out @@ -1708,21 +1747,20 @@ function pointerDown( const hit = hitTest(cursor) if (hit) { const target = measurements.value.find((m) => m.id === hit.measurementId) - // Circles are exclusively manipulated via their two handles — - // center (which translates the whole circle while keeping its - // radius) and edge (resize). Body and label hits don't drag. - const isCircleBodyHit = - target?.type === "circle" && hit.kind !== "handle" - // When a placement tool is active and the hit is on a circle's - // body, fall through to placement entirely — don't select, don't - // suppress the click. This lets the user draw a new measurement - // on top of an existing circle. Handles still claim the press - // so the circle can be reshaped from within an active tool too. - if (isCircleBodyHit && activeTool.value !== "none") { + // Closed shapes (circle, rectangle) are only draggable from their + // handles — corner dots for rect, center / edge for circle. Body + // hits select but never start a drag, so big shapes don't drift + // when the user clicks inside them. When a placement tool is + // active, body hits fall through entirely so the user can draw a + // new measurement on top of an existing closed shape. + const isClosedBodyHit = + (target?.type === "circle" || target?.type === "rectangle") && + hit.kind !== "handle" + if (isClosedBodyHit && activeTool.value !== "none") { return "placement" } selectedId.value = hit.measurementId - if (target && !isCircleBodyHit) { + if (target && !isClosedBodyHit) { const mode: DragMode = hit.kind === "handle" ? "handle" : "move" dragState = { mode, @@ -1978,6 +2016,10 @@ function onKeyDown(e: KeyboardEvent) { if (selectedId.value !== null) { selectedId.value = null drawOverlay() + return + } + if (isFullscreen.value) { + isFullscreen.value = false } return } @@ -2306,7 +2348,16 @@ watch([viewScale, viewOffsetX, viewOffsetY], () => {