diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx index f9412ac..833e1d5 100644 --- a/Front/client/src/components/SlideCanvas.tsx +++ b/Front/client/src/components/SlideCanvas.tsx @@ -21,6 +21,10 @@ import type { UserSelection, NormalizedContent, } from "../types/designAgent"; +import { + clampZoneMove, + crossedDragThreshold, +} from "./slideCanvasDragMath"; interface SlideCanvasProps { slidePlan: SlidePlan | null; @@ -465,10 +469,9 @@ export default function SlideCanvas({ const makeResizeHandler = ( direction: ResizeDir ) => (ev: React.MouseEvent) => { - // resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는 - // frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만 - // zone 자유 배치. - if (!isPendingLayout || !onZoneResize) return; + // resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix — + // frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐. + if ((!isPendingLayout && !isEditMode) || !onZoneResize) return; if (!measuredSlideBody) return; ev.preventDefault(); ev.stopPropagation(); @@ -485,6 +488,12 @@ export default function SlideCanvas({ const affectsTop = direction === "top" || direction === "nw" || direction === "ne"; 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 dx = (mv.clientX - startMouseX) / slideBodyWidthPx; const dy = (mv.clientY - startMouseY) / slideBodyHeightPx; @@ -511,6 +520,7 @@ export default function SlideCanvas({ const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); + if (iframeEl) iframeEl.style.pointerEvents = prevIframePE; }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); @@ -532,7 +542,7 @@ export default function SlideCanvas({ ev: React.MouseEvent ) => { ev.stopPropagation(); - const canDrag = !!(isPendingLayout && measuredSlideBody && onZoneResize); + const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize); const startMouseX = ev.clientX; const startMouseY = ev.clientY; const startGeom = { ...localGeom }; @@ -543,25 +553,26 @@ export default function SlideCanvas({ ? H_SCALED * measuredSlideBody!.h : 1; 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) => { if (!canDrag) return; const dxPx = mv.clientX - startMouseX; const dyPx = mv.clientY - startMouseY; - if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) { + if (!dragged && crossedDragThreshold(dxPx, dyPx)) { dragged = true; } if (dragged) { - const dx = dxPx / slideBodyWidthPx; - const dy = dyPx / slideBodyHeightPx; - const newX = Math.max( - 0, - Math.min(1 - startGeom.w, startGeom.x + dx) - ); - const newY = Math.max( - 0, - Math.min(1 - startGeom.h, startGeom.y + dy) + const { x: newX, y: newY } = clampZoneMove( + startGeom, + dxPx, + dyPx, + slideBodyWidthPx, + slideBodyHeightPx ); onZoneResize!({ [zone.zone_id]: { @@ -576,6 +587,7 @@ export default function SlideCanvas({ const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); + if (iframeEl) iframeEl.style.pointerEvents = prevIframePE; if (!dragged) { // 단순 click 으로 처리 — onZoneClick. onZoneClick?.(zone.id); @@ -671,6 +683,8 @@ export default function SlideCanvas({ } ${ isDragOver ? "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 ? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]" : !isEditMode @@ -747,11 +761,12 @@ export default function SlideCanvas({ )} - {/* Step C : zone resize handles — 8 방향. pendingLayout 모드만 활성 - (frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X). + {/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성. + 2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전 + 들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음. edge handle (top/bottom/left/right) : 한 boundary 이동 corner handle (nw/ne/sw/se) : 두 boundary 동시. */} - {isPendingLayout && onZoneResize && ( + {(isPendingLayout || isEditMode) && onZoneResize && ( <> {/* top edge */}
)} + + {/* 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 && ( + <> +
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 이동 — 드래그" + /> +
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 이동 — 드래그" + /> +
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 이동 — 드래그" + /> +
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. */} +
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 이동 — 드래그하여 위치 변경" + /> + + )}
); })} diff --git a/Front/client/src/components/slideCanvasDragMath.test.ts b/Front/client/src/components/slideCanvasDragMath.test.ts new file mode 100644 index 0000000..e0437d7 --- /dev/null +++ b/Front/client/src/components/slideCanvasDragMath.test.ts @@ -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); + }); +}); diff --git a/Front/client/src/components/slideCanvasDragMath.ts b/Front/client/src/components/slideCanvasDragMath.ts new file mode 100644 index 0000000..0423127 --- /dev/null +++ b/Front/client/src/components/slideCanvasDragMath.ts @@ -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 }; +}