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:
2026-05-22 13:35:34 +09:00
parent 9388e25e76
commit bd8bcf748b
3 changed files with 259 additions and 19 deletions

View File

@@ -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>
); );
})} })}

View 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);
});
});

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