Compare commits

...

7 Commits

Author SHA1 Message Date
Samuel Prevost
93b05f554c feat(result): full-bleed corrected-image card
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
The corrected-image card was inscribed in the same max-w-4xl column as
the controls, which capped the canvas at ~896px even on wide displays.
Wrap just that one card in a full-bleed shell (relative left-1/2 -tx-1/2
w-screen) so it spans the viewport width while leaving the surrounding
controls/diagnostics/download in their narrower column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:27:44 +02:00
Samuel Prevost
f3411a14bd fix(measurements): rectangle stays a rectangle on corner drag
Dragging a corner used to move only that corner, turning the rect into
an arbitrary quad. Constrain to axis-aligned: dragged corner follows
the cursor, the diagonally-opposite corner stays put, the two adjacent
corners are recomputed from the cross of (dragged.x, opp.y) and
(opp.x, dragged.y) so the shape stays rectangular. Corner indices stay
stable (c0 is still the logical TL even if the box flips through
itself), so handle keys keep tracking the same corner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:23:45 +02:00
Samuel Prevost
bb8ea1929f 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>
2026-04-25 10:19:53 +02:00
Samuel Prevost
c6249aad5d fix(measurements): drag survives leaving the canvas
Drag was wired to canvas-level mousemove/mouseup, so any time the
cursor left the canvas (or a fast drag outpaced the canvas-bound event
firing) the drag died silently. Also onMouseLeave ended the drag, which
made off-edge nudges feel like they "didn't work".

Move the live mousemove/mouseup listeners to the window for the
duration of a press, leaving onMouseLeave to clear only the placement
preview. Window listeners are attached on the press and detached on
release (and on unmount, in case the user navigates away mid-drag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:13:09 +02:00
Samuel Prevost
f1d32d0fb2 fix(measurements): handles drag on the first pixel, like datum view
Handles previously had a 3 px screen-space dead zone before drag started
(the click-vs-drag heuristic). On a precision-positioning tool that
makes nudges feel mushy, and the datum editor — which uses Konva drag
— has no such threshold. Drop the threshold; selection still happens
on pointerDown so a pure click that doesn't move never enters
pointerMove and the position never drifts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:07:44 +02:00
Samuel Prevost
a499e97361 feat(measurements): add rectangle tool
Fourth measurement type alongside line/ellipse/angle. 2-click placement
(opposite corners → axis-aligned), corners normalised to TL/TR/BR/BL on
commit then free to drag individually so the user can make the quad
non-rectangular if they need to. Index ordering stays stable across
drags — same as the rect *datum* in step 3.

Hit test: 6 px tolerance on each of the 4 edges, plus an interior point-
in-polygon fill so a big rect can be grabbed anywhere. Handles still
win priority over the geometry fill so a corner grab always beats a
whole-rect grab.

Label at the centroid: `w × h mm · area mm²`. Width/height are means of
opposing edges so reshape doesn't make readings jump; area is computed
with the shoelace formula and survives non-rectangular drags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:04:27 +02:00
Samuel Prevost
923e969bdf chore(logo): replace squirrel SVG with the 📐 emoji
SkwikLogo renders 📐 in an inline span sized via the existing `size`
prop (font-size = round(size * 0.9)). Favicon becomes an SVG with a
single <text> element rendering the same emoji.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:04:00 +02:00
4 changed files with 407 additions and 388 deletions

View File

@ -1,43 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
<!-- Simplified SkwikLogo for favicon — squirrel head with hard hat and goggles -->
<!-- Head -->
<ellipse cx="32" cy="36" rx="16" ry="17" fill="#1a1a1a"/>
<!-- Ear tufts -->
<path d="M16 24 Q10 14 6 17 Q5 22 14 26" fill="#1a1a1a"/>
<path d="M48 24 Q54 14 58 17 Q59 22 50 26" fill="#1a1a1a"/>
<!-- Cheeks -->
<ellipse cx="20" cy="40" rx="5" ry="4" fill="#2a2a2a"/>
<ellipse cx="44" cy="40" rx="5" ry="4" fill="#2a2a2a"/>
<!-- Eyes -->
<circle cx="25" cy="34" r="4" fill="#fff"/>
<circle cx="39" cy="34" r="4" fill="#fff"/>
<circle cx="26" cy="33.5" r="2.4" fill="#1a1a1a"/>
<circle cx="40" cy="33.5" r="2.4" fill="#1a1a1a"/>
<!-- Eye shine -->
<circle cx="27" cy="32.5" r="0.9" fill="#fff"/>
<circle cx="41" cy="32.5" r="0.9" fill="#fff"/>
<!-- Nose -->
<ellipse cx="32" cy="41" rx="2.2" ry="1.5" fill="#555"/>
<!-- Mouth -->
<path d="M29.5 44 Q32 46.5 34.5 44" fill="none" stroke="#555" stroke-width="1" stroke-linecap="round"/>
<!-- Safety goggles -->
<path d="M16 28 Q24 24 32 25.5 Q40 24 48 28" fill="none" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/>
<ellipse cx="24" cy="28" rx="5" ry="3.5" fill="none" stroke="#f59e0b" stroke-width="2"/>
<ellipse cx="40" cy="28" rx="5" ry="3.5" fill="none" stroke="#f59e0b" stroke-width="2"/>
<path d="M29 28 Q32 26.5 35 28" fill="none" stroke="#f59e0b" stroke-width="1.5"/>
<ellipse cx="24" cy="28" rx="3.5" ry="2" fill="#f59e0b" opacity="0.15"/>
<ellipse cx="40" cy="28" rx="3.5" ry="2" fill="#f59e0b" opacity="0.15"/>
<!-- Hard hat -->
<path d="M12 22 Q12 8 32 6 Q52 8 52 22" fill="none" stroke="#f59e0b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Hat brim -->
<path d="M10 22 L54 22" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/>
<!-- Hat dome highlight -->
<path d="M24 12 Q32 9 40 12" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
<text
x="50%"
y="50%"
text-anchor="middle"
dominant-baseline="central"
font-size="56"
>📐</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 214 B

View File

@ -32,7 +32,7 @@ const viewOffsetX = ref(0)
const viewOffsetY = ref(0)
// Tool state
type ToolMode = "none" | "line" | "ellipse" | "angle"
type ToolMode = "none" | "line" | "rectangle" | "ellipse" | "angle"
const activeTool = ref<ToolMode>("none")
const showGrid = ref(false)
const gridSpacingMm = ref(10)
@ -48,6 +48,14 @@ interface LineMeasurement extends BaseMeasurement {
a: Point
b: Point
}
// Corner ordering [TL, TR, BR, BL] mirrors the RectDatum convention from
// src/types/index.ts. Indices stay stable across drags even if the user
// crosses corners visual + hit-tests don't depend on TL actually being
// top-left after reshape, matching the datum editor's behaviour.
interface RectMeasurement extends BaseMeasurement {
type: "rectangle"
corners: [Point, Point, Point, Point]
}
interface EllipseMeasurement extends BaseMeasurement {
type: "ellipse"
center: Point
@ -60,7 +68,11 @@ interface AngleMeasurement extends BaseMeasurement {
armA: Point
armB: Point
}
type Measurement = LineMeasurement | EllipseMeasurement | AngleMeasurement
type Measurement =
| LineMeasurement
| RectMeasurement
| EllipseMeasurement
| AngleMeasurement
const measurements = ref<Measurement[]>([])
const selectedId = ref<string | null>(null)
@ -79,7 +91,11 @@ let isPanning = false
let panStart = { x: 0, y: 0 }
let lastPinchDist = 0
// Drag state for moving/reshaping committed measurements.
// Drag state for moving/reshaping committed measurements. We don't gate
// drag-start on a movement threshold handles begin tracking the cursor
// on the very first move event so positioning feels precise (matches
// Konva's behaviour in the datum editor). A pure click without movement
// never enters pointerMove, so selection on its own remains drift-free.
type DragMode = "none" | "move" | "handle"
interface DragState {
mode: DragMode
@ -90,14 +106,8 @@ interface DragState {
startImg: Point
// Snapshot of the measurement at drag start, for delta-based updates.
startSnapshot: Measurement
// Did this pointer down actually move? Used to distinguish click vs drag.
moved: boolean
}
let dragState: DragState | null = null
// Pixel threshold in screen space before a press becomes a drag. Small enough
// that intentional drags feel responsive; large enough that a shaky click
// still registers as a click.
const DRAG_THRESHOLD_PX = 3
function loadImg() {
const image = new Image()
@ -293,6 +303,35 @@ function angleDegrees(m: AngleMeasurement): number {
return (rad * 180) / Math.PI
}
// Width = mean of TLTR and BLBR (the two "horizontal" sides under the
// stored ordering). Height = mean of TLBL and TRBR. This averages out
// minor non-rectangular skew the user may introduce while reshaping.
function rectDimensionsMm(m: RectMeasurement): { widthMm: number; heightMm: number } {
const [tl, tr, br, bl] = m.corners
const wTop = Math.hypot(tr.x - tl.x, tr.y - tl.y)
const wBot = Math.hypot(br.x - bl.x, br.y - bl.y)
const hLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y)
const hRight = Math.hypot(br.x - tr.x, br.y - tr.y)
return {
widthMm: (wTop + wBot) / 2 / props.scalePxPerMm,
heightMm: (hLeft + hRight) / 2 / props.scalePxPerMm,
}
}
// Shoelace area of the quadrilateral, sign-stripped handles skewed/
// reshaped rectangles correctly. Crossed quads will give a smaller area
// (common shoelace behaviour); we accept that since we don't auto-reorder.
function rectAreaMm2(m: RectMeasurement): number {
const [p0, p1, p2, p3] = m.corners
const cross =
p0.x * p1.y - p1.x * p0.y +
p1.x * p2.y - p2.x * p1.y +
p2.x * p3.y - p3.x * p2.y +
p3.x * p0.y - p0.x * p3.y
const areaPx2 = Math.abs(cross) / 2
return areaPx2 / (props.scalePxPerMm * props.scalePxPerMm)
}
function formatMm(v: number): string {
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
}
@ -303,10 +342,21 @@ function formatArea(v: number): string {
return v.toFixed(2)
}
// Spec for rectangle area readout: 0 decimals when 100 mm², else 1.
function formatRectArea(v: number): string {
if (v >= 100) return v.toFixed(0)
return v.toFixed(1)
}
function measurementLabel(m: Measurement): string {
if (m.type === "line") {
return `${formatMm(lineLengthMm(m))} mm`
}
if (m.type === "rectangle") {
const { widthMm, heightMm } = rectDimensionsMm(m)
const area = rectAreaMm2(m)
return `${widthMm.toFixed(1)} × ${heightMm.toFixed(1)} mm · ${formatRectArea(area)} mm²`
}
if (m.type === "ellipse") {
const { semiMajor, semiMinor } = ellipseAxesMm(m)
const area = Math.PI * semiMajor * semiMinor
@ -317,16 +367,34 @@ function measurementLabel(m: Measurement): string {
function measurementTypeLabel(m: Measurement): string {
if (m.type === "line") return "Line"
if (m.type === "rectangle") return "Rect"
if (m.type === "ellipse") return "Ellipse"
return "Angle"
}
// Side-panel summary uses the shorter "w×h mm" without the area suffix per
// the spec a separate format from the on-canvas label.
function measurementSummaryValue(m: Measurement): string {
if (m.type === "rectangle") {
const { widthMm, heightMm } = rectDimensionsMm(m)
return `${widthMm.toFixed(1)}×${heightMm.toFixed(1)} mm`
}
return measurementLabel(m)
}
// Anchor point in image space where we place the label. Chosen per type so
// the label sits in a predictable, non-occluding position.
function labelAnchor(m: Measurement): Point {
if (m.type === "line") {
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
}
if (m.type === "rectangle") {
const [p0, p1, p2, p3] = m.corners
return {
x: (p0.x + p1.x + p2.x + p3.x) / 4,
y: (p0.y + p1.y + p2.y + p3.y) / 4,
}
}
if (m.type === "ellipse") {
return m.center
}
@ -370,6 +438,8 @@ function drawMeasurement(
if (m.type === "line") {
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} else if (m.type === "rectangle") {
drawRectGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} else if (m.type === "ellipse") {
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
} else {
@ -403,6 +473,35 @@ function drawLineGeometry(
drawHandle(ctx, sb, handleColor, isSelected)
}
function drawRectGeometry(
ctx: CanvasRenderingContext2D,
m: RectMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
) {
const screenCorners = m.corners.map(imgToScreen)
ctx.beginPath()
for (let i = 0; i < screenCorners.length; i++) {
const p = screenCorners[i]
if (!p) continue
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
ctx.setLineDash(isSelected ? [] : [6, 3])
ctx.stroke()
ctx.setLineDash([])
// Don't fill the interior keeps what's underneath visible, matching
// the line/ellipse/angle visual style.
for (const p of screenCorners) {
drawHandle(ctx, p, handleColor, isSelected)
}
}
function drawEllipseGeometry(
ctx: CanvasRenderingContext2D,
m: EllipseMeasurement,
@ -612,6 +711,12 @@ function drawPlacementPreview(ctx: CanvasRenderingContext2D) {
ctx.moveTo(sPts[0].x, sPts[0].y)
ctx.lineTo(sCursor.x, sCursor.y)
ctx.stroke()
} else if (activeTool.value === "rectangle" && sPts.length >= 1 && sPts[0] && sCursor) {
const a = sPts[0]
const b = sCursor
ctx.beginPath()
ctx.rect(a.x, a.y, b.x - a.x, b.y - a.y)
ctx.stroke()
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
const center = sPts[0]
const endA = sPts[1] ?? sCursor
@ -700,6 +805,24 @@ function pointToSegmentDistance(
return Math.hypot(p.x - qx, p.y - qy)
}
// Standard ray-cast point-in-polygon. Works for any simple quadrilateral
// (including reshaped non-rect cases) and gracefully degrades to a small
// or zero region for crossed quads which is what we want, since a
// "crossed" rectangle is effectively user error.
function pointInPolygon(p: Point, poly: Point[]): boolean {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) {
const pi = poly[i]
const pj = poly[j]
if (!pi || !pj) continue
const intersect =
pi.y > p.y !== pj.y > p.y &&
p.x < ((pj.x - pi.x) * (p.y - pi.y)) / (pj.y - pi.y) + pi.x
if (intersect) inside = !inside
}
return inside
}
// Returns the min screen-space distance from cursor to the ellipse curve.
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
function ellipseCurveDistance(
@ -743,6 +866,14 @@ function getHandlePositions(m: Measurement): { key: string; pt: Point }[] {
{ key: "b", pt: m.b },
]
}
if (m.type === "rectangle") {
return [
{ key: "c0", pt: m.corners[0] },
{ key: "c1", pt: m.corners[1] },
{ key: "c2", pt: m.corners[2] },
{ key: "c3", pt: m.corners[3] },
]
}
if (m.type === "ellipse") {
return [
{ key: "center", pt: m.center },
@ -803,6 +934,23 @@ function hitTest(cursorScreen: Point): HitResult | null {
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else if (m.type === "rectangle") {
const sc = m.corners.map(imgToScreen)
// Edge-near test for thin grabs along the border.
let edgeHit = false
for (let i = 0; i < 4; i++) {
const a = sc[i]
const b = sc[(i + 1) % 4]
if (a && b && pointToSegmentDistance(cursorScreen, a, b) <= LINE_HIT_PX) {
edgeHit = true
break
}
}
// Interior-fill test so a big rect is grabbable from anywhere
// inside, not just along the 6px edge band.
if (edgeHit || pointInPolygon(cursorScreen, sc)) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else if (m.type === "ellipse") {
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
@ -838,6 +986,28 @@ function commitPlacement() {
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "rectangle" && pts.length === 2) {
const [p1, p2] = pts as [Point, Point]
// Normalise so corners are TL/TR/BR/BL regardless of click order.
const minX = Math.min(p1.x, p2.x)
const maxX = Math.max(p1.x, p2.x)
const minY = Math.min(p1.y, p2.y)
const maxY = Math.max(p1.y, p2.y)
const id = nanoid()
measurements.value.push({
id,
type: "rectangle",
colorIndex: colorCounter,
corners: [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
],
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "ellipse" && pts.length === 3) {
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
const id = nanoid()
@ -872,7 +1042,8 @@ function commitPlacement() {
function handlePlacementClick(imgPt: Point) {
if (activeTool.value === "none") return
placementPoints.value.push(imgPt)
const needed = activeTool.value === "line" ? 2 : 3
const needed =
activeTool.value === "line" || activeTool.value === "rectangle" ? 2 : 3
if (placementPoints.value.length >= needed) {
commitPlacement()
}
@ -919,6 +1090,17 @@ function cloneMeasurement(m: Measurement): Measurement {
if (m.type === "line") {
return { ...m, a: { ...m.a }, b: { ...m.b } }
}
if (m.type === "rectangle") {
return {
...m,
corners: [
{ ...m.corners[0] },
{ ...m.corners[1] },
{ ...m.corners[2] },
{ ...m.corners[3] },
],
}
}
if (m.type === "ellipse") {
return {
...m,
@ -950,6 +1132,17 @@ function applyDrag(
b: { x: original.b.x + dx, y: original.b.y + dy },
}
}
if (original.type === "rectangle") {
return {
...original,
corners: [
{ x: original.corners[0].x + dx, y: original.corners[0].y + dy },
{ x: original.corners[1].x + dx, y: original.corners[1].y + dy },
{ x: original.corners[2].x + dx, y: original.corners[2].y + dy },
{ x: original.corners[3].x + dx, y: original.corners[3].y + dy },
],
}
}
if (original.type === "ellipse") {
return {
...original,
@ -969,6 +1162,48 @@ function applyDrag(
if (original.type === "line") {
if (handleKey === "a") return { ...original, a: { x: original.a.x + dx, y: original.a.y + dy } }
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
} else if (original.type === "rectangle") {
// Constrain to an axis-aligned rectangle: the dragged corner
// follows the cursor, the diagonally-opposite corner stays put,
// and the two adjacent corners are recomputed from the cross of
// (dragged.x, opp.y) and (opp.x, dragged.y) so the shape stays
// rectangular. Corner indices stay stable c0 is still the
// logical TL even if the box flips through itself.
const cornerIdx: 0 | 1 | 2 | 3 | null =
handleKey === "c0" ? 0 :
handleKey === "c1" ? 1 :
handleKey === "c2" ? 2 :
handleKey === "c3" ? 3 : null
if (cornerIdx !== null) {
const moving = {
x: original.corners[cornerIdx].x + dx,
y: original.corners[cornerIdx].y + dy,
}
const oppIdx = ((cornerIdx + 2) % 4) as 0 | 1 | 2 | 3
const opp = { ...original.corners[oppIdx] }
const next: [Point, Point, Point, Point] = [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
]
next[cornerIdx] = moving
next[oppIdx] = opp
if (cornerIdx === 0 || cornerIdx === 2) {
// TL BR diagonal: TR=(BR.x, TL.y), BL=(TL.x, BR.y).
const tl = next[0]
const br = next[2]
next[1] = { x: br.x, y: tl.y }
next[3] = { x: tl.x, y: br.y }
} else {
// TR BL diagonal: TL=(BL.x, TR.y), BR=(TR.x, BL.y).
const tr = next[1]
const bl = next[3]
next[0] = { x: bl.x, y: tr.y }
next[2] = { x: tr.x, y: bl.y }
}
return { ...original, corners: next }
}
} else if (original.type === "ellipse") {
if (handleKey === "center") {
// Dragging the ellipse center translates the whole ellipse so
@ -1014,36 +1249,43 @@ function updateMeasurement(id: string, next: Measurement) {
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 hit = hitTest(cursor)
if (!hit) {
// Empty-space click: deselect, fall through to pan behavior.
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
if (hit) {
selectedId.value = hit.measurementId
const target = measurements.value.find((m) => m.id === hit.measurementId)
if (target) {
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()
return "measurement"
}
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
dragState = {
mode,
measurementId: target.id,
handleKey: hit.handleKey,
startImg: screenToImg(screenX, screenY),
startSnapshot: cloneMeasurement(target),
moved: false,
// Empty-space press.
if (activeTool.value !== "none") return "placement"
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
}
drawOverlay()
return "measurement"
return "pan"
}
function pointerMove(screenX: number, screenY: number): boolean {
@ -1051,14 +1293,6 @@ function pointerMove(screenX: number, screenY: number): boolean {
const nowImg = screenToImg(screenX, screenY)
const dxImg = nowImg.x - dragState.startImg.x
const dyImg = nowImg.y - dragState.startImg.y
if (!dragState.moved) {
// Convert image-space delta back to screen-space via viewScale; easier
// than tracking the original screen cursor separately.
const screenDx = dxImg * viewScale.value
const screenDy = dyImg * viewScale.value
if (Math.hypot(screenDx, screenDy) < DRAG_THRESHOLD_PX) return true
dragState.moved = true
}
const next = applyDrag(
dragState.startSnapshot,
dragState.mode,
@ -1093,55 +1327,94 @@ function onWheel(e: WheelEvent) {
redraw()
}
// Once a press starts on the canvas we listen at the window level so the
// drag survives the cursor leaving the canvas (or moving faster than the
// browser's canvas-bound event firing). Re-attached on each press, removed
// when the drag/pan ends.
function attachWindowDragListeners() {
window.addEventListener("mousemove", onWindowMouseMove)
window.addEventListener("mouseup", onWindowMouseUp)
}
function detachWindowDragListeners() {
window.removeEventListener("mousemove", onWindowMouseMove)
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) {
const { x, y } = getCanvasXY(e)
if (activeTool.value !== "none") {
// Placement tools ignore mousedown; they commit on click so a user
// can drag-scroll accidentally without placing a spurious point.
return
}
suppressNextClick = false
const outcome = pointerDown(x, y)
if (outcome === "pan") {
if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "pan") {
isPanning = true
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
}
if (dragState || isPanning) attachWindowDragListeners()
}
function onMouseMove(e: MouseEvent) {
function onWindowMouseMove(e: MouseEvent) {
if (dragState) {
const { x, y } = getCanvasXY(e)
pointerMove(x, y)
return
}
if (isPanning) {
viewOffsetX.value = e.clientX - panStart.x
viewOffsetY.value = e.clientY - panStart.y
redraw()
}
}
function onWindowMouseUp() {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
function onMouseMove(e: MouseEvent) {
// While a drag/pan is in flight the window listener handles motion;
// here we only need the placement-preview cursor.
if (dragState || isPanning) return
if (activeTool.value !== "none") {
const { x, y } = getCanvasXY(e)
placementCursor.value = screenToImg(x, y)
drawOverlay()
return
}
if (!isPanning) return
viewOffsetX.value = e.clientX - panStart.x
viewOffsetY.value = e.clientY - panStart.y
redraw()
}
function onMouseUp() {
pointerUp()
isPanning = false
// Mouseup that lands inside the canvas covered by the window listener
// too, but we keep this so a quick click without movement still ends
// cleanly even if for some reason the window handler misses.
if (dragState || isPanning) {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
}
function onMouseLeave() {
pointerUp()
isPanning = false
// Don't end the drag here the window listener takes over while the
// cursor is outside the canvas. Just clear the placement preview.
placementCursor.value = null
drawOverlay()
}
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
// 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 imgPt = screenToImg(x, y)
handlePlacementClick(imgPt)
@ -1163,12 +1436,15 @@ function onTouchStart(e: TouchEvent) {
isPanning = false
} else if (e.touches.length === 1 && t0) {
const { x, y } = getCanvasXY(t0)
if (activeTool.value !== "none") {
placementCursor.value = screenToImg(x, y)
return
}
// Always hit-test first so a tap on an existing handle reshapes
// it even when a placement tool is active.
suppressNextClick = false
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
panStart = {
x: t0.clientX - viewOffsetX.value,
@ -1266,6 +1542,10 @@ const placementHint = computed<string | null>(() => {
if (n === 0) return "Click the first endpoint."
return "Click the second endpoint."
}
if (activeTool.value === "rectangle") {
if (n === 0) return "Click the first corner."
return "Click the opposite corner."
}
if (activeTool.value === "ellipse") {
if (n === 0) return "Click the ellipse center."
if (n === 1) return "Click the first semi-axis endpoint."
@ -1281,7 +1561,7 @@ const measurementSummaries = computed(() => {
id: m.id,
type: m.type,
typeLabel: measurementTypeLabel(m),
label: measurementLabel(m),
label: measurementSummaryValue(m),
color: getDatumColor(m.colorIndex),
selected: m.id === selectedId.value,
}))
@ -1391,6 +1671,7 @@ onMounted(() => {
onUnmounted(() => {
resizeObs?.disconnect()
window.removeEventListener("keydown", onKeyDown)
detachWindowDragListeners()
})
watch(() => props.imageUrl, loadImg)
@ -1454,6 +1735,30 @@ watch(() => props.scalePxPerMm, () => { drawOverlay() })
</svg>
Ellipse
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'rectangle'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('rectangle')"
>
<svg
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"
>
<rect x="4" y="6" width="16" height="12" rx="1" />
</svg>
Rect
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="

View File

@ -806,20 +806,25 @@ async function download() {
</CardContent>
</Card>
<!-- Corrected image with tools -->
<Card>
<CardHeader>
<CardTitle class="text-base"
>Corrected Image</CardTitle
>
</CardHeader>
<CardContent>
<CorrectedImageViewer
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
/>
</CardContent>
</Card>
<!-- Corrected image with tools full-bleed to use the whole page width
even though the surrounding column is capped at max-w-4xl. -->
<div
class="relative left-1/2 w-screen -translate-x-1/2 px-4"
>
<Card>
<CardHeader>
<CardTitle class="text-base"
>Corrected Image</CardTitle
>
</CardHeader>
<CardContent>
<CorrectedImageViewer
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
/>
</CardContent>
</Card>
</div>
<!-- Download -->
<div class="flex flex-col items-center gap-3 pb-8">

View File

@ -10,271 +10,14 @@ withDefaults(
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
:width="size"
:height="size"
aria-label="Skwik squirrel logo"
>
<!-- Tail (fluffy, curling up behind) -->
<path
d="M50 52 Q58 40 54 28 Q52 22 48 20
Q55 18 56 12 Q56 8 52 10
Q48 12 46 18"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Body -->
<ellipse
cx="32"
cy="46"
rx="12"
ry="10"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Head -->
<ellipse
cx="32"
cy="28"
rx="12"
ry="13"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Ear tufts -->
<path
d="M22 18 Q18 10 14 12 Q12 16 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M42 18 Q46 10 50 12 Q52 16 44 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Eyes -->
<circle
cx="27"
cy="26"
r="3"
fill="currentColor"
/>
<circle
cx="37"
cy="26"
r="3"
fill="currentColor"
/>
<!-- Eye shine -->
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
<!-- Nose -->
<ellipse
cx="32"
cy="32"
rx="2"
ry="1.5"
fill="currentColor"
/>
<!-- Cheeks -->
<ellipse
cx="24"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<ellipse
cx="40"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<!-- Mouth -->
<path
d="M30 34 Q32 36 34 34"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<!-- Arms holding ruler -->
<path
d="M20 42 L12 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M44 42 L52 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Ruler (held diagonally) -->
<rect
x="4"
y="34"
width="22"
height="4"
rx="0.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
transform="rotate(-15 15 36)"
/>
<!-- Ruler tick marks -->
<line
x1="8" y1="34" x2="8" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="12" y1="34" x2="12" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="16" y1="34" x2="16" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="20" y1="34" x2="20" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<!-- Safety goggles on forehead -->
<path
d="M22 20 Q27 17 32 18 Q37 17 42 20"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Goggle lenses -->
<ellipse
cx="26"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<!-- Goggle bridge -->
<path
d="M29.5 20 Q32 19 34.5 20"
fill="none"
stroke="#f59e0b"
stroke-width="1.2"
/>
<!-- Goggle lens tint -->
<ellipse
cx="26"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<ellipse
cx="38"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<!-- Hard hat -->
<path
d="M18 16 Q18 6 32 5 Q46 6 46 16"
fill="none"
stroke="#f59e0b"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Hat brim -->
<path
d="M16 16 L48 16"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Hat dome highlight -->
<path
d="M26 9 Q32 7 38 9"
fill="none"
stroke="#fbbf24"
stroke-width="1.5"
stroke-linecap="round"
opacity="0.6"
/>
<!-- Feet -->
<ellipse
cx="26"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
<span
class="inline-flex items-center justify-center leading-none select-none"
:style="{
width: size + 'px',
height: size + 'px',
fontSize: Math.round(size * 0.9) + 'px',
}"
aria-label="Skwik logo"
role="img"
>📐</span>
</template>