202 lines
7.5 KiB
TypeScript
202 lines
7.5 KiB
TypeScript
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
|
||
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
|
||
//
|
||
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
|
||
// NOT touched — it has its own independent geometry model (per-side
|
||
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
|
||
// must not regress.
|
||
//
|
||
// Two responsibilities live here:
|
||
//
|
||
// 1. Drag-vs-click classification — a pointer must travel more than
|
||
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
|
||
// before mousedown→mousemove is treated as a drag. Below the
|
||
// threshold the gesture stays a click, which the caller surfaces as
|
||
// `onZoneClick(zone.id)` in `onUp`.
|
||
//
|
||
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
|
||
// moved zone fully inside the slide body. Width/height are preserved
|
||
// verbatim by this helper — only `x` and `y` move.
|
||
//
|
||
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
|
||
// drive them directly. The numeric contract is the inline behavior that
|
||
// existed before the extraction; this file is a relocation, not a behavior
|
||
// change.
|
||
|
||
export const DRAG_THRESHOLD_PX = 5;
|
||
|
||
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
|
||
// SlideCanvas.tsx `beginDrag` onMove (lines 1092–1219 of the u8 patch).
|
||
// Slide-absolute percent coordinate space (0–100 on both axes), matching
|
||
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
|
||
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
|
||
// u3). The math is the contract Codex #16 verified post-u8 — this file
|
||
// is a relocation, not a behavior change. SlideCanvas calls it from a
|
||
// single hook so future tweaks need to update one place + the vitest
|
||
// suite alongside.
|
||
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
|
||
|
||
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
|
||
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
|
||
* shape moves end-to-end through stamper → overlay → persisted axis. */
|
||
export interface ImagePercentGeom {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export type ImageDragDirection =
|
||
| "move"
|
||
| "left"
|
||
| "right"
|
||
| "top"
|
||
| "bottom"
|
||
| "nw"
|
||
| "ne"
|
||
| "sw"
|
||
| "se";
|
||
|
||
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
|
||
*
|
||
* Contract (must match the inline u8 math Codex #16 verified):
|
||
* • `direction === "move"` → translate only; w/h preserved verbatim;
|
||
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
|
||
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
|
||
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
|
||
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
|
||
* independent edges (same per-edge clamp as above).
|
||
* • Corner handle with `aspectLocked=true` → preserves
|
||
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
|
||
* stays fixed; the floored axis is `w` and `h` is re-derived so the
|
||
* aspect ratio is exact even at the minSize floor.
|
||
*
|
||
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
|
||
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
|
||
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
|
||
* Pure / deterministic / no DOM access — vitest drives it directly. */
|
||
export function clampImagePercentGeometry(
|
||
startGeom: ImagePercentGeom,
|
||
dxPercent: number,
|
||
dyPercent: number,
|
||
direction: ImageDragDirection,
|
||
aspectLocked: boolean,
|
||
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||
): ImagePercentGeom {
|
||
if (direction === "move") {
|
||
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
|
||
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
|
||
return { x, y, w: startGeom.w, h: startGeom.h };
|
||
}
|
||
|
||
const affectsLeft =
|
||
direction === "left" || direction === "nw" || direction === "sw";
|
||
const affectsRight =
|
||
direction === "right" || direction === "ne" || direction === "se";
|
||
const affectsTop =
|
||
direction === "top" || direction === "nw" || direction === "ne";
|
||
const affectsBottom =
|
||
direction === "bottom" || direction === "sw" || direction === "se";
|
||
const isCorner =
|
||
direction === "nw" ||
|
||
direction === "ne" ||
|
||
direction === "sw" ||
|
||
direction === "se";
|
||
|
||
const rightEdge = startGeom.x + startGeom.w;
|
||
const bottomEdge = startGeom.y + startGeom.h;
|
||
let x = startGeom.x;
|
||
let y = startGeom.y;
|
||
let w = startGeom.w;
|
||
let h = startGeom.h;
|
||
|
||
if (affectsRight) {
|
||
const maxW = 100 - startGeom.x;
|
||
const floor = Math.min(minSize, maxW);
|
||
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
|
||
}
|
||
if (affectsBottom) {
|
||
const maxH = 100 - startGeom.y;
|
||
const floor = Math.min(minSize, maxH);
|
||
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
|
||
}
|
||
if (affectsLeft) {
|
||
const floor = Math.min(minSize, rightEdge);
|
||
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
|
||
w = rightEdge - x;
|
||
}
|
||
if (affectsTop) {
|
||
const floor = Math.min(minSize, bottomEdge);
|
||
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
|
||
h = bottomEdge - y;
|
||
}
|
||
|
||
if (isCorner && aspectLocked) {
|
||
const baseAspect =
|
||
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
|
||
if (baseAspect > 0) {
|
||
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
|
||
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
|
||
let newW = w;
|
||
let newH = newW / baseAspect;
|
||
if (newH > maxH) {
|
||
newH = maxH;
|
||
newW = newH * baseAspect;
|
||
}
|
||
if (newW > maxW) {
|
||
newW = maxW;
|
||
newH = newW / baseAspect;
|
||
}
|
||
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
|
||
if (newW < wFloor) {
|
||
newW = wFloor;
|
||
newH = newW / baseAspect;
|
||
}
|
||
w = newW;
|
||
h = newH;
|
||
x = affectsLeft ? rightEdge - w : startGeom.x;
|
||
y = affectsTop ? bottomEdge - h : startGeom.y;
|
||
}
|
||
}
|
||
|
||
return { x, y, w, h };
|
||
}
|
||
|
||
/** Returns true once the pointer has travelled far enough from the mousedown
|
||
* origin to be treated as a drag rather than a click. */
|
||
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
|
||
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
|
||
}
|
||
|
||
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
|
||
* Mirrors the shape the SlideCanvas pipeline already uses for
|
||
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
|
||
export interface ZoneFracGeom {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
|
||
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
|
||
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
|
||
*
|
||
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
|
||
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
|
||
* `measuredSlideBody` precondition, so this helper does not re-guard
|
||
* divide-by-zero. */
|
||
export function clampZoneMove(
|
||
startGeom: ZoneFracGeom,
|
||
dxPx: number,
|
||
dyPx: number,
|
||
slideBodyWidthPx: number,
|
||
slideBodyHeightPx: number,
|
||
): { x: number; y: number } {
|
||
const dx = dxPx / slideBodyWidthPx;
|
||
const dy = dyPx / slideBodyHeightPx;
|
||
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
|
||
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
|
||
return { x, y };
|
||
}
|