feat(measurements): rect click-through, color-as-selection, fullscreen, label clamping
- 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:
parent
8c7f4078df
commit
590ba16596
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user