fix(capture): Zoom/Pan vs Rotate gesture modes; drop dead ↻ overlay
Pinch/drag were applying zoom and tilt together on Measure. Add an explicit Zoom/Pan | Rotate segmented toggle (default Zoom/Pan). Stage gestures are exclusive per mode; tilt/zoom sliders stay available. Remove the bottom-center ↻ handle that sat on tick B (hitTick won, so the icon looked live but only moved the tick).
This commit is contained in:
@@ -76,7 +76,7 @@
|
|||||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||||
<span class="val">{tiltShown}°</span>
|
<span class="val">{tiltShown}°</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle</p>
|
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist (Rotate gesture mode)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
let speciesLoadErr = '';
|
let speciesLoadErr = '';
|
||||||
|
|
||||||
let cropFill = getCropFill();
|
let cropFill = getCropFill();
|
||||||
|
/** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */
|
||||||
|
let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||||
$: tape = state.axis.tape;
|
$: tape = state.axis.tape;
|
||||||
@@ -200,12 +202,39 @@
|
|||||||
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
||||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||||
|
|
||||||
|
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={gestureMode === 'zoompan'}
|
||||||
|
aria-pressed={gestureMode === 'zoompan'}
|
||||||
|
data-testid="gesture-mode-zoompan"
|
||||||
|
on:click={() => (gestureMode = 'zoompan')}
|
||||||
|
>Zoom / Pan</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={gestureMode === 'rotate'}
|
||||||
|
aria-pressed={gestureMode === 'rotate'}
|
||||||
|
data-testid="gesture-mode-rotate"
|
||||||
|
on:click={() => (gestureMode = 'rotate')}
|
||||||
|
>Rotate</button>
|
||||||
|
</div>
|
||||||
|
<p class="nudge mode-hint">
|
||||||
|
{#if gestureMode === 'zoompan'}
|
||||||
|
Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider.
|
||||||
|
{:else}
|
||||||
|
Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if maskedPreview}
|
{#if maskedPreview}
|
||||||
<PhotoStage
|
<PhotoStage
|
||||||
mode="tape"
|
mode="tape"
|
||||||
blob={maskedPreview}
|
blob={maskedPreview}
|
||||||
{tape}
|
{tape}
|
||||||
{cropFill}
|
{cropFill}
|
||||||
|
{gestureMode}
|
||||||
onTapeChange={onTapeChange}
|
onTapeChange={onTapeChange}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -227,7 +256,7 @@
|
|||||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||||
<span class="val">{tiltShown}°</span>
|
<span class="val">{tiltShown}°</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle</p>
|
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -408,6 +437,17 @@
|
|||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex; gap: 0; margin: 10px 0 4px;
|
||||||
|
border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
|
||||||
|
border: 0; background: #1d1d1d; color: #aaa; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mode-btn + .mode-btn { border-left: 1px solid #444; }
|
||||||
|
.mode-btn.active { background: #00e5ff; color: #002; }
|
||||||
|
.mode-hint { margin: 0 0 10px; }
|
||||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||||
.preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
|
.preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
|
||||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
||||||
/** When false, tape overlay is display-only (Measure step). */
|
/** When false, tape overlay is display-only (Measure step). */
|
||||||
export let interactive = true;
|
export let interactive = true;
|
||||||
|
/**
|
||||||
|
* Exclusive stage gestures so pinch/drag don't fight.
|
||||||
|
* zoompan (default): pinch zoom + one-finger pan; ignore rotate gestures.
|
||||||
|
* rotate: two-finger twist only; ignore pan/zoom from those gestures.
|
||||||
|
* Tilt/zoom sliders outside the stage stay available either way.
|
||||||
|
*/
|
||||||
|
export let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
let stage: HTMLDivElement | undefined;
|
let stage: HTMLDivElement | undefined;
|
||||||
@@ -139,7 +146,7 @@
|
|||||||
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||||
|
|
||||||
// ---- tape pointer interactions ----
|
// ---- tape pointer interactions ----
|
||||||
type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot';
|
type DragKind = null | 'pan' | 'tickA' | 'tickB';
|
||||||
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
||||||
let pointers = new Map<number, { x: number; y: number }>();
|
let pointers = new Map<number, { x: number; y: number }>();
|
||||||
let pinchStartDist = 0;
|
let pinchStartDist = 0;
|
||||||
@@ -170,12 +177,6 @@
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitRot = (x: number, y: number): boolean => {
|
|
||||||
const cx = STAGE_W / 2;
|
|
||||||
const cy = STAGE_H - 26;
|
|
||||||
return Math.hypot(x - cx, y - cy) <= 22;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
if (!interactive || mode !== 'tape') return;
|
if (!interactive || mode !== 'tape') return;
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||||
@@ -198,11 +199,10 @@
|
|||||||
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hitRot(p.x, p.y)) {
|
// One-finger pan only in Zoom/Pan mode (Rotate mode uses slider + two-finger twist).
|
||||||
drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
if (gestureMode === 'zoompan') {
|
||||||
return;
|
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||||
}
|
}
|
||||||
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerMove = (e: PointerEvent) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
@@ -212,19 +212,24 @@
|
|||||||
const pts = [...pointers.values()];
|
const pts = [...pointers.values()];
|
||||||
const dx = pts[1].x - pts[0].x;
|
const dx = pts[1].x - pts[0].x;
|
||||||
const dy = pts[1].y - pts[0].y;
|
const dy = pts[1].y - pts[0].y;
|
||||||
const dist = Math.hypot(dx, dy) || 1;
|
if (gestureMode === 'zoompan') {
|
||||||
const ratio = dist / pinchStartDist;
|
const dist = Math.hypot(dx, dy) || 1;
|
||||||
const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP;
|
const ratio = dist / pinchStartDist;
|
||||||
const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio));
|
const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP;
|
||||||
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
|
const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio));
|
||||||
const angle = Math.atan2(dy, dx);
|
emit({ ...live, zoom: z });
|
||||||
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
|
} else {
|
||||||
emit({ ...live, zoom: z, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) });
|
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
|
||||||
|
emit({ ...live, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const p = stagePoint(e.clientX, e.clientY);
|
const p = stagePoint(e.clientX, e.clientY);
|
||||||
if (drag.kind === 'pan') {
|
if (drag.kind === 'pan') {
|
||||||
|
if (gestureMode !== 'zoompan') return;
|
||||||
emit({
|
emit({
|
||||||
...live,
|
...live,
|
||||||
panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP,
|
panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP,
|
||||||
@@ -235,13 +240,6 @@
|
|||||||
? clampTickPair(p.y, live.tickB)
|
? clampTickPair(p.y, live.tickB)
|
||||||
: clampTickPair(live.tickA, p.y);
|
: clampTickPair(live.tickA, p.y);
|
||||||
emit({ ...live, ...pair });
|
emit({ ...live, ...pair });
|
||||||
} else if (drag.kind === 'rot') {
|
|
||||||
const cx = STAGE_W / 2;
|
|
||||||
const cy = STAGE_H / 2;
|
|
||||||
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
|
||||||
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
|
||||||
const delta = ((a1 - a0) * 180) / Math.PI;
|
|
||||||
emit({ ...live, tiltDeg: clampTiltDeg(drag.tilt0 + delta * ROTATE_DAMP) });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,7 +249,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
if (!interactive || mode !== 'tape') return;
|
if (!interactive || mode !== 'tape' || gestureMode !== 'zoompan') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||||
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
||||||
@@ -261,6 +259,7 @@
|
|||||||
if (!interactive || mode !== 'tape') return;
|
if (!interactive || mode !== 'tape') return;
|
||||||
const fine = e.shiftKey;
|
const fine = e.shiftKey;
|
||||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
if (gestureMode !== 'rotate') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const step = fine ? 0.1 : 1;
|
const step = fine ? 0.1 : 1;
|
||||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||||
@@ -275,9 +274,11 @@
|
|||||||
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
||||||
}
|
}
|
||||||
} else if (e.key === '+' || e.key === '=') {
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
|
if (gestureMode !== 'zoompan') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||||
} else if (e.key === '-' || e.key === '_') {
|
} else if (e.key === '-' || e.key === '_') {
|
||||||
|
if (gestureMode !== 'zoompan') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
||||||
}
|
}
|
||||||
@@ -340,6 +341,7 @@
|
|||||||
<div
|
<div
|
||||||
class="tw"
|
class="tw"
|
||||||
class:dragging={!!drag}
|
class:dragging={!!drag}
|
||||||
|
class:mode-rotate={gestureMode === 'rotate'}
|
||||||
bind:this={tapeWrap}
|
bind:this={tapeWrap}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Tape alignment stage"
|
aria-label="Tape alignment stage"
|
||||||
@@ -366,12 +368,6 @@
|
|||||||
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
||||||
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
||||||
{/each}
|
{/each}
|
||||||
{#if interactive}
|
|
||||||
<g class="rot-handle">
|
|
||||||
<circle cx={STAGE_W / 2} cy={STAGE_H - 26} r="17" fill="rgba(0,0,0,.6)" stroke="#00e5ff" stroke-width="1.5" />
|
|
||||||
<text x={STAGE_W / 2} y={STAGE_H - 21} fill="#00e5ff" font-size="15" text-anchor="middle">↻</text>
|
|
||||||
</g>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
</svg>
|
||||||
{#if decoding && imgW === 0}
|
{#if decoding && imgW === 0}
|
||||||
<div class="loading">Decoding photo…</div>
|
<div class="loading">Decoding photo…</div>
|
||||||
@@ -400,6 +396,7 @@
|
|||||||
touch-action: none; user-select: none; cursor: grab;
|
touch-action: none; user-select: none; cursor: grab;
|
||||||
}
|
}
|
||||||
.tw.dragging { cursor: grabbing; }
|
.tw.dragging { cursor: grabbing; }
|
||||||
|
.tw.mode-rotate { cursor: ew-resize; }
|
||||||
.vb {
|
.vb {
|
||||||
position: absolute; top: 0; left: 0;
|
position: absolute; top: 0; left: 0;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
@@ -413,5 +410,4 @@
|
|||||||
.band.bot { bottom: 0; }
|
.band.bot { bottom: 0; }
|
||||||
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||||
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
||||||
.rot-handle { pointer-events: auto; cursor: ew-resize; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user