// 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 }; }