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,
|
||||
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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>
|
||||
) => {
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user