feat(#81): IMP-54 frontend zone editing UI (u1~u4 edit-mode body-drag + emerald highlight + pure drag-math helper + vitest)
u1: 4 perimeter edge strips (~8px) + top-left grip chip at zone wrapper
provide an edit-mode pointer-event surface (zIndex 25) so wrapper-level
handleZoneMouseDown becomes reachable in edit mode. Wrapper stays
pointerEvents:none and iframe stays pointerEvents:auto to preserve
text-edit reachability (A8 guardrail). Resize handles (z-30) win in
overlap regions. Iframe pointer-events temporarily forced none during
drag to prevent mouseup leak.
u2: Edit-mode isSelected branch reuses selectedZoneId with emerald visual
(border-emerald-500 / bg-emerald-500/10) distinct from pendingLayout
blue, decorative-only (pointerEvents:none inherits via wrapper rules).
u3: Pure drag math extracted to slideCanvasDragMath.ts — DRAG_THRESHOLD_PX,
crossedDragThreshold(dx, dy) strict Math.hypot > 5, and clampZoneMove
pixel→fraction conversion with x∈[0, 1-w] / y∈[0, 1-h] clamp.
Resize math (makeResizeHandler) untouched.
u4: Vitest coverage (12 tests, 3 describe blocks) on the pure helper:
threshold strict boundary at (3,4)/(5,0)/(0,5), above-threshold,
negative-symmetric, clamp negative→0, max-edge → 1-w / 1-h, per-axis
independence, non-square 500×250 slide-body, return-shape {x,y} only.
Stage 4 verify: pnpm exec vitest run client/src/components/slideCanvasDragMath.test.ts → 12/12 PASS.
Scope: edit-mode UX only. No HTML text modification, no automatic frame swap, no MDX touched.
Depends on: #9 IMP-09 (--override-zone-geometry backend wire), #80 IMP-52 (user_overrides.json zone_geometries persistence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,10 @@ import type {
|
|||||||
UserSelection,
|
UserSelection,
|
||||||
NormalizedContent,
|
NormalizedContent,
|
||||||
} from "../types/designAgent";
|
} from "../types/designAgent";
|
||||||
|
import {
|
||||||
|
clampZoneMove,
|
||||||
|
crossedDragThreshold,
|
||||||
|
} from "./slideCanvasDragMath";
|
||||||
|
|
||||||
interface SlideCanvasProps {
|
interface SlideCanvasProps {
|
||||||
slidePlan: SlidePlan | null;
|
slidePlan: SlidePlan | null;
|
||||||
@@ -465,10 +469,9 @@ export default function SlideCanvas({
|
|||||||
const makeResizeHandler = (
|
const makeResizeHandler = (
|
||||||
direction: ResizeDir
|
direction: ResizeDir
|
||||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
|
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
|
||||||
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
|
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
|
||||||
// zone 자유 배치.
|
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
|
||||||
if (!isPendingLayout || !onZoneResize) return;
|
|
||||||
if (!measuredSlideBody) return;
|
if (!measuredSlideBody) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -485,6 +488,12 @@ export default function SlideCanvas({
|
|||||||
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
||||||
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
||||||
|
|
||||||
|
// 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
|
||||||
|
// (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
|
||||||
|
const iframeEl = iframeRef.current;
|
||||||
|
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||||
|
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||||
|
|
||||||
const onMove = (mv: MouseEvent) => {
|
const onMove = (mv: MouseEvent) => {
|
||||||
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
||||||
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
||||||
@@ -511,6 +520,7 @@ export default function SlideCanvas({
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mousemove", onMove);
|
||||||
document.removeEventListener("mouseup", onUp);
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||||
};
|
};
|
||||||
document.addEventListener("mousemove", onMove);
|
document.addEventListener("mousemove", onMove);
|
||||||
document.addEventListener("mouseup", onUp);
|
document.addEventListener("mouseup", onUp);
|
||||||
@@ -532,7 +542,7 @@ export default function SlideCanvas({
|
|||||||
ev: React.MouseEvent<HTMLDivElement>
|
ev: React.MouseEvent<HTMLDivElement>
|
||||||
) => {
|
) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const canDrag = !!(isPendingLayout && measuredSlideBody && onZoneResize);
|
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
|
||||||
const startMouseX = ev.clientX;
|
const startMouseX = ev.clientX;
|
||||||
const startMouseY = ev.clientY;
|
const startMouseY = ev.clientY;
|
||||||
const startGeom = { ...localGeom };
|
const startGeom = { ...localGeom };
|
||||||
@@ -543,25 +553,26 @@ export default function SlideCanvas({
|
|||||||
? H_SCALED * measuredSlideBody!.h
|
? H_SCALED * measuredSlideBody!.h
|
||||||
: 1;
|
: 1;
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
const dragThresholdPx = 5;
|
|
||||||
|
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
|
||||||
|
const iframeEl = iframeRef.current;
|
||||||
|
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||||
|
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||||
|
|
||||||
const onMove = (mv: MouseEvent) => {
|
const onMove = (mv: MouseEvent) => {
|
||||||
if (!canDrag) return;
|
if (!canDrag) return;
|
||||||
const dxPx = mv.clientX - startMouseX;
|
const dxPx = mv.clientX - startMouseX;
|
||||||
const dyPx = mv.clientY - startMouseY;
|
const dyPx = mv.clientY - startMouseY;
|
||||||
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
|
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
|
||||||
dragged = true;
|
dragged = true;
|
||||||
}
|
}
|
||||||
if (dragged) {
|
if (dragged) {
|
||||||
const dx = dxPx / slideBodyWidthPx;
|
const { x: newX, y: newY } = clampZoneMove(
|
||||||
const dy = dyPx / slideBodyHeightPx;
|
startGeom,
|
||||||
const newX = Math.max(
|
dxPx,
|
||||||
0,
|
dyPx,
|
||||||
Math.min(1 - startGeom.w, startGeom.x + dx)
|
slideBodyWidthPx,
|
||||||
);
|
slideBodyHeightPx
|
||||||
const newY = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(1 - startGeom.h, startGeom.y + dy)
|
|
||||||
);
|
);
|
||||||
onZoneResize!({
|
onZoneResize!({
|
||||||
[zone.zone_id]: {
|
[zone.zone_id]: {
|
||||||
@@ -576,6 +587,7 @@ export default function SlideCanvas({
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mousemove", onMove);
|
||||||
document.removeEventListener("mouseup", onUp);
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||||
if (!dragged) {
|
if (!dragged) {
|
||||||
// 단순 click 으로 처리 — onZoneClick.
|
// 단순 click 으로 처리 — onZoneClick.
|
||||||
onZoneClick?.(zone.id);
|
onZoneClick?.(zone.id);
|
||||||
@@ -671,6 +683,8 @@ export default function SlideCanvas({
|
|||||||
} ${
|
} ${
|
||||||
isDragOver
|
isDragOver
|
||||||
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
||||||
|
: isSelected && isEditMode
|
||||||
|
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||||
: isSelected && !isEditMode
|
: isSelected && !isEditMode
|
||||||
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
||||||
: !isEditMode
|
: !isEditMode
|
||||||
@@ -747,11 +761,12 @@ export default function SlideCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step C : zone resize handles — 8 방향. pendingLayout 모드만 활성
|
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
|
||||||
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
|
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
|
||||||
|
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
|
||||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||||
{isPendingLayout && onZoneResize && (
|
{(isPendingLayout || isEditMode) && onZoneResize && (
|
||||||
<>
|
<>
|
||||||
{/* top edge */}
|
{/* top edge */}
|
||||||
<div
|
<div
|
||||||
@@ -819,6 +834,60 @@ export default function SlideCanvas({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
|
||||||
|
wrapper sets pointerEvents:none in edit mode (see above) to
|
||||||
|
preserve iframe text-edit clicks (A8 guardrail), so the
|
||||||
|
wrapper-level handleZoneMouseDown is unreachable in edit mode.
|
||||||
|
These 4 perimeter strips + top-left grip provide a separate
|
||||||
|
pointer-event surface routing into handleZoneMouseDown.
|
||||||
|
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
|
||||||
|
gesture wins in overlap regions, and ABOVE the iframe so the
|
||||||
|
strips intercept the perimeter while the un-covered iframe
|
||||||
|
interior keeps text-edit reachability intact.
|
||||||
|
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||||
|
so these surfaces are only needed in edit mode. */}
|
||||||
|
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onMouseDown={handleZoneMouseDown}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||||
|
title="zone 이동 — 드래그"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onMouseDown={handleZoneMouseDown}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||||
|
title="zone 이동 — 드래그"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onMouseDown={handleZoneMouseDown}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||||
|
title="zone 이동 — 드래그"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onMouseDown={handleZoneMouseDown}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||||
|
title="zone 이동 — 드래그"
|
||||||
|
/>
|
||||||
|
{/* visible grip affordance — placed below the section label
|
||||||
|
(top-1 left-1 container) so the two don't overlap. */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleZoneMouseDown}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||||
|
title="zone 이동 — 드래그하여 위치 변경"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
107
Front/client/src/components/slideCanvasDragMath.test.ts
Normal file
107
Front/client/src/components/slideCanvasDragMath.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
|
||||||
|
// (`Front/client/src/components/slideCanvasDragMath.ts`).
|
||||||
|
//
|
||||||
|
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
|
||||||
|
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
|
||||||
|
// • Clamp negative delta to 0 on both axes.
|
||||||
|
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
|
||||||
|
//
|
||||||
|
// The helpers are pure (no React, no DOM) so we drive them directly with
|
||||||
|
// numeric inputs — no fake timers, no fetch stubs, no component mount.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DRAG_THRESHOLD_PX,
|
||||||
|
clampZoneMove,
|
||||||
|
crossedDragThreshold,
|
||||||
|
type ZoneFracGeom,
|
||||||
|
} from "./slideCanvasDragMath";
|
||||||
|
|
||||||
|
describe("DRAG_THRESHOLD_PX", () => {
|
||||||
|
it("is 5", () => {
|
||||||
|
expect(DRAG_THRESHOLD_PX).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crossedDragThreshold", () => {
|
||||||
|
it("returns false for zero movement (still a click)", () => {
|
||||||
|
expect(crossedDragThreshold(0, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
|
||||||
|
expect(crossedDragThreshold(3, 4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false at exactly the threshold along each axis", () => {
|
||||||
|
// strict inequality: Math.hypot(5, 0) === 5, not > 5
|
||||||
|
expect(crossedDragThreshold(5, 0)).toBe(false);
|
||||||
|
expect(crossedDragThreshold(0, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true once distance exceeds threshold", () => {
|
||||||
|
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
|
||||||
|
expect(crossedDragThreshold(6, 0)).toBe(true);
|
||||||
|
expect(crossedDragThreshold(0, 6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats negative deltas symmetrically (Euclidean distance)", () => {
|
||||||
|
expect(crossedDragThreshold(-3, -4)).toBe(false);
|
||||||
|
expect(crossedDragThreshold(-4, -4)).toBe(true);
|
||||||
|
expect(crossedDragThreshold(-6, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampZoneMove", () => {
|
||||||
|
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
|
||||||
|
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
|
||||||
|
const W = 1000;
|
||||||
|
const H = 1000;
|
||||||
|
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
|
||||||
|
|
||||||
|
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
|
||||||
|
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative delta to 0 on both axes", () => {
|
||||||
|
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
|
||||||
|
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
|
||||||
|
x: 1 - baseGeom.w, // 0.7
|
||||||
|
y: 1 - baseGeom.h, // 0.6
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the two axes independently (negative x, in-bounds y)", () => {
|
||||||
|
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours non-square slide bodies via per-axis division", () => {
|
||||||
|
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
|
||||||
|
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
|
||||||
|
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
|
||||||
|
// it just inherits JS number precision. y stays exact since it clamps to
|
||||||
|
// the boundary `1 - h`.
|
||||||
|
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
|
||||||
|
expect(result.x).toBeCloseTo(0.3, 10);
|
||||||
|
expect(result.y).toBe(1 - baseGeom.h); // 0.6
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only { x, y } — width / height are preserved by the caller", () => {
|
||||||
|
const out = clampZoneMove(baseGeom, 0, 0, W, H);
|
||||||
|
expect(out).toEqual({ x: 0.1, y: 0.2 });
|
||||||
|
expect("w" in out).toBe(false);
|
||||||
|
expect("h" in out).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
Front/client/src/components/slideCanvasDragMath.ts
Normal file
64
Front/client/src/components/slideCanvasDragMath.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user