Files
C.E.L_Slide_test2/Front/client/src/components/SlideCanvas.tsx
kyeongmin bd8bcf748b 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>
2026-05-22 13:35:34 +09:00

899 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SlideCanvas — Phase Z 실제 결과 (final.html) iframe 표시.
*
* 역할 (Phase 1 — MDX upload → 파이프라인 실행 → 결과 표시):
* - MDX 업로드 후 server 가 Phase Z 실행
* - run_id 의 final.html 을 iframe 으로 표시
* - 시뮬레이션 / mock 슬라이드 layout 렌더 X
*
* 이전 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
* Phase Z Engine header / Summary footer) 모두 제거 — frontend 가 슬라이드를 *만들지 않음*,
* backend 결과를 *읽기만 함*.
*
* Layout / zone / region / frame 변경 UI 는 좌우 패널 (LayoutPanel / FramePanel)
* 에서 수행. 본 컴포넌트는 *결과 viewer*.
*/
import { useRef, useEffect, useState } from "react";
import { Loader2, Upload as UploadIcon } from "lucide-react";
import type {
SlidePlan,
UserSelection,
NormalizedContent,
} from "../types/designAgent";
import {
clampZoneMove,
crossedDragThreshold,
} from "./slideCanvasDragMath";
interface SlideCanvasProps {
slidePlan: SlidePlan | null;
normalizedContent: NormalizedContent | null;
userSelection: UserSelection;
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
finalHtmlUrl?: string;
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
slideOverrideCss?: string;
/** 파이프라인 실행 중 표시 (loading state). */
isPipelineRunning?: boolean;
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
isPendingLayout?: boolean;
pendingLayoutId?: string | null;
onCancelPendingLayout?: () => void;
/** 편집 모드 텍스트 변경 발생 시 — Home.tsx 가 hasPendingChanges 트리거. */
onContentEdit?: () => void;
/** Zone overlay 클릭 — 우측 Frame 탭으로 전환 + 그 zone 의 frame 후보 표시. */
onZoneClick?: (zoneId: string) => void;
/** 슬라이드 박스 (zone overlay 외 빈 영역) 클릭 — 우측 Layout 탭으로 전환. */
onSlideClick?: () => void;
onRegionClick?: (regionId: string) => void;
onFrameSelect?: (frameId: string) => void;
onSectionDrop?: (sectionId: string, zoneId: string) => void;
onLayoutResize?: (groupId: string, sizes: number[]) => void;
onZoneResize?: (
geometries: Record<string, { x: number; y: number; w: number; h: number }>
) => void;
}
const SLIDE_W = 1280;
const SLIDE_H = 720;
export default function SlideCanvas({
slidePlan,
userSelection,
finalHtmlUrl,
slideOverrideCss,
isPipelineRunning,
isPendingLayout,
pendingLayoutId,
onCancelPendingLayout,
onContentEdit,
onZoneClick,
onSlideClick,
onSectionDrop,
onZoneResize,
}: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
// iframe 안 final.html 의 실제 .zone DOM boundingClientRect 측정값 (정규화 0~1).
// key = data-zone-position (e.g., "top" / "bottom" / "primary"). 이게 SlidePlan
// 의 zone.zone_id 와 1:1 일치하므로 overlay rendering 시 매칭 가능.
const [measuredZones, setMeasuredZones] = useState<
Record<string, { x: number; y: number; w: number; h: number }>
>({});
// iframe 안 .slide-body 의 bbox (정규화 0~1, 1280×720 기준). slide-base (title /
// divider / footer) 는 그 외부. pendingLayout 모드 시 이 영역 안에만 빈 layout
// overlay 를 깔아서 slide-base 는 그대로 유지.
const [measuredSlideBody, setMeasuredSlideBody] = useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// pendingLayout 과 배타적 (충돌 방지).
const [isEditMode, setIsEditMode] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
// 편집 모드 outline CSS — 한 번만 주입 (id 로 중복 방지)
let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null;
if (!editStyle) {
editStyle = doc.createElement("style");
editStyle.id = "phase-z-edit-style";
editStyle.textContent = `
[contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); cursor: text; }
[contenteditable]:focus { outline: 2px solid #00C853 !important; outline-offset: 2px; }
`;
doc.head.appendChild(editStyle);
}
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((e: Event) => void) | null = null;
if (isEditMode) {
doc.designMode = "on";
doc.querySelectorAll(".slide *").forEach((el) => {
if (editableTags.includes((el as HTMLElement).tagName)) {
(el as HTMLElement).setAttribute("contenteditable", "true");
}
});
// 편집 발생 시 hasPendingChanges 트리거.
inputHandler = () => {
onContentEdit?.();
};
doc.addEventListener("input", inputHandler);
} else {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
}
return () => {
if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler);
}
};
}, [isEditMode, finalHtmlUrl, onContentEdit]);
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => {
if (isPendingLayout && isEditMode) setIsEditMode(false);
}, [isPendingLayout, isEditMode]);
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
useEffect(() => {
setMeasuredZones({});
setMeasuredSlideBody(null);
}, [finalHtmlUrl]);
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const updateScale = () => {
const padding = 32; // 컨테이너 양쪽 여백 (1rem * 2)
const cw = el.clientWidth - padding;
const ch = el.clientHeight - padding;
if (cw <= 0 || ch <= 0) return;
const sw = cw / SLIDE_W;
const sh = ch / SLIDE_H;
setScale(Math.min(sw, sh));
};
updateScale();
const ro = new ResizeObserver(updateScale);
ro.observe(el);
window.addEventListener("resize", updateScale);
return () => {
ro.disconnect();
window.removeEventListener("resize", updateScale);
};
}, []);
// Empty state — MDX 업로드 전.
const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
const embeddedSrc = finalHtmlUrl
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
: undefined;
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
const W_SCALED = SLIDE_W * scale;
const H_SCALED = SLIDE_H * scale;
return (
<div
ref={containerRef}
className="flex-1 flex items-center justify-center bg-slate-100 p-4 relative overflow-hidden"
>
{isEmpty && (
<div className="text-center text-slate-400 select-none">
<UploadIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm font-medium"> MDX .</p>
<p className="text-xs text-slate-300 mt-1">
"슬라이드 플랜 생성하기" .
</p>
</div>
)}
{isPipelineRunning && (
<div className="text-center text-slate-500 select-none">
<Loader2 className="w-10 h-10 mx-auto mb-3 text-blue-500 animate-spin" />
<p className="text-sm font-bold">Phase Z ...</p>
<p className="text-xs text-slate-400 mt-1">
MDX V4 /
</p>
</div>
)}
{showSlideBox && (
<div
className="relative overflow-hidden cursor-pointer bg-white shadow-[0_40px_100px_-20px_rgba(0,0,0,0.2)]"
style={{ width: W_SCALED, height: H_SCALED }}
onClick={(e) => {
// wrapper 자체 클릭 (zone overlay 가 아닌 영역) = slide 전체 = Layout 탭.
// zone overlay 는 stopPropagation 으로 여기까지 안 옴.
if (e.target === e.currentTarget && onSlideClick) onSlideClick();
}}
title={
isPendingLayout
? `Pending layout: ${pendingLayoutId} — slide-body 만 변경. section 카드 → zone drop → frame 선택 → 재생성`
: "슬라이드 전체 클릭 → 우측 Layout 후보"
}
>
{/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */}
{isPendingLayout && onCancelPendingLayout && (
<button
onClick={(e) => {
e.stopPropagation();
onCancelPendingLayout();
}}
className="absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2 py-1 rounded bg-slate-800 text-white hover:bg-slate-700 shadow"
style={{ pointerEvents: "auto" }}
>
</button>
)}
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
{!isPendingLayout && finalHtmlUrl && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditMode((p) => !p);
}}
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
isEditMode
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
style={{ pointerEvents: "auto" }}
title={
isEditMode
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
: "텍스트 직접 편집 모드 진입"
}
>
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
</button>
)}
<div
style={{
width: SLIDE_W,
height: SLIDE_H,
transform: `scale(${scale})`,
transformOrigin: "top left",
position: "absolute",
top: 0,
left: 0,
pointerEvents: "none",
}}
>
<iframe
ref={iframeRef}
src={embeddedSrc}
title="Phase Z 렌더 결과"
className="w-full h-full border-0 block"
scrolling="no"
sandbox="allow-same-origin allow-scripts"
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
onLoad={(e) => {
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
// backend auto-mode detection script 를 trigger 해서 html.embedded
// class 를 붙이고 standalone-only body 규칙을 reset.
try {
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return;
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
if (slideOverrideCss && slideOverrideCss.trim()) {
const overrideStyle = doc.createElement("style");
overrideStyle.setAttribute("data-purpose", "slide-level-override");
overrideStyle.textContent = slideOverrideCss;
doc.head.appendChild(overrideStyle);
}
// ── Zone DOM 측정 ──
// backend final.html 의 .zone[data-zone-position="..."] 요소를
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
// overlay 가 이 측정값으로 그려지므로 backend 의 layout (slide-base
// padding / dynamic ratios / grid) 그대로 반영됨.
const zoneEls = doc.querySelectorAll(
".zone[data-zone-position]"
);
const measured: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {};
zoneEls.forEach((el) => {
const r = (el as HTMLElement).getBoundingClientRect();
const pos = (el as HTMLElement).dataset.zonePosition;
if (!pos) return;
measured[pos] = {
x: r.left / SLIDE_W,
y: r.top / SLIDE_H,
w: r.width / SLIDE_W,
h: r.height / SLIDE_H,
};
});
setMeasuredZones(measured);
// .slide-body 측정 — pendingLayout 모드 시 이 영역에만 빈
// overlay 를 깔아서 slide-base (title / footer) 는 유지.
const bodyEl = doc.querySelector(".slide-body");
if (bodyEl) {
const r = (bodyEl as HTMLElement).getBoundingClientRect();
setMeasuredSlideBody({
x: r.left / SLIDE_W,
y: r.top / SLIDE_H,
w: r.width / SLIDE_W,
h: r.height / SLIDE_H,
});
}
} catch (err) {
console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
}
}}
/>
</div>
{/* ── pendingLayout 시 slide-body 영역 overlay ──
slide-base (title / divider / footer) 는 final.html 그대로 유지.
slide-body 영역에만 흰 dashed overlay 깔아서 기존 .zone 들 가리고
새 layout zone 안내 표시. */}
{isPendingLayout && measuredSlideBody && (
<div
className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
style={{
left: `${measuredSlideBody.x * 100}%`,
top: `${measuredSlideBody.y * 100}%`,
width: `${measuredSlideBody.w * 100}%`,
height: `${measuredSlideBody.h * 100}%`,
pointerEvents: "none",
}}
>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center select-none">
<span className="text-[10px] font-black uppercase tracking-widest text-amber-500 block mb-1">
Pending Body Layout
</span>
<span className="text-lg font-bold text-slate-500 block">
{pendingLayoutId}
</span>
<span className="text-[10px] text-slate-400 block mt-1">
1. section zone drop &nbsp;·&nbsp; 2. zone frame &nbsp;·&nbsp; 3.
</span>
</div>
</div>
</div>
)}
{/* ── Zone overlay layer ──
normal mode : measuredZones (= iframe 안 .zone DOM bbox) 우선.
pendingLayout : zone.position (computeZonePositions 결과 = slide-body
내부 0~1) 을 measuredSlideBody 안 절대 정규화로 변환.
isEditMode : zone overlay 반투명 + pointer-events: none → 텍스트 편집
방해 X. resize handle 만 pointer-events: auto 로 활성. */}
{slidePlan?.zones.map((zone) => {
// selectedZoneId 는 zone.id 또는 zone.zone_id 중 어느 쪽이든 매칭 (utils
// 의 getSelectedZone 도 양쪽 보므로 일관).
const isSelected =
userSelection.selectedZoneId === zone.id ||
userSelection.selectedZoneId === zone.zone_id;
const isDragOver = dragOverZoneId === zone.id;
// Step B : zone overlay 라벨 = section_ids 기반 (S1, S1+S2 등).
// backend zone position (top/bottom/primary) 은 부 라벨 / tooltip 으로.
// pendingLayout 모드에서는 override 만 사용 (default 무시 — 빈 상태로 시작).
const sectionIds = isPendingLayout
? userSelection.overrides.zone_sections?.[zone.zone_id] ?? []
: userSelection.overrides.zone_sections?.[zone.zone_id] ??
zone.section_ids;
const sectionLabel =
sectionIds.length > 0
? sectionIds
.map((s) => `S${s.split("-").pop() ?? s}`)
.join("+")
: "(empty)";
// 좌표 결정 (schema 통일 — 사용자 override 는 항상 slide-body 내부 0~1) :
// override 있음 (zone_geometries) → slide-body 기준 → 1280×720 절대 변환.
// pending mode + override 없음 → zone.position (slide-body 기준) 변환.
// normal mode + override 없음 → measuredZones (1280×720 절대) 직접.
const overrideGeom =
userSelection.overrides.zone_geometries?.[zone.zone_id] as
| { x: number; y: number; w: number; h: number }
| undefined;
const localGeom = overrideGeom ?? {
x: zone.position.x,
y: zone.position.y,
w: zone.position.width,
h: zone.position.height,
};
let x: number, y: number, width: number, height: number;
if (overrideGeom && measuredSlideBody) {
// 사용자 override → slide-body 기준
x = measuredSlideBody.x + overrideGeom.x * measuredSlideBody.w;
y = measuredSlideBody.y + overrideGeom.y * measuredSlideBody.h;
width = overrideGeom.w * measuredSlideBody.w;
height = overrideGeom.h * measuredSlideBody.h;
} else if (isPendingLayout && measuredSlideBody) {
x = measuredSlideBody.x + zone.position.x * measuredSlideBody.w;
y = measuredSlideBody.y + zone.position.y * measuredSlideBody.h;
width = zone.position.width * measuredSlideBody.w;
height = zone.position.height * measuredSlideBody.h;
} else {
const measured = measuredZones[zone.zone_id];
x = measured?.x ?? zone.position.x;
y = measured?.y ?? zone.position.y;
width = measured?.w ?? zone.position.width;
height = measured?.h ?? zone.position.height;
}
// Step C : pending mode 의 zone overlay resize handles — independent 모델.
// 각 zone 이 독립 박스 — 인접 zone 영향 X. 자유 배치 (gap / 겹침 사용자 책임).
// boundary drag 모델 (인접 자동 조정) 은 grid 같은 묶음 layout 에 적합한데,
// 사용자 의도 = zone position drag 까지 자유롭게 → independent 가 자연.
type ResizeDir =
| "top"
| "bottom"
| "left"
| "right"
| "nw"
| "ne"
| "sw"
| "se";
const makeResizeHandler = (
direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => {
// 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();
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom: { x: number; y: number; w: number; h: number } =
overrideGeom ? { ...overrideGeom } : { ...localGeom };
const slideBodyWidthPx = W_SCALED * measuredSlideBody.w;
const slideBodyHeightPx = H_SCALED * measuredSlideBody.h;
const minSize = 0.05;
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";
// 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;
let { x, y, w, h } = startGeom;
if (affectsRight) {
w = Math.max(minSize, Math.min(1 - startGeom.x, startGeom.w + dx));
}
if (affectsBottom) {
h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
}
if (affectsLeft) {
// 좌측 경계 이동 — w 줄이고 x 늘림 (또는 반대)
const newW = Math.max(minSize, startGeom.w - dx);
x = Math.max(0, startGeom.x + (startGeom.w - newW));
w = newW;
}
if (affectsTop) {
const newH = Math.max(minSize, startGeom.h - dy);
y = Math.max(0, startGeom.y + (startGeom.h - newH));
h = newH;
}
onZoneResize({ [zone.zone_id]: { x, y, w, h } });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const handleResizeTop = makeResizeHandler("top");
const handleResizeBottom = makeResizeHandler("bottom");
const handleResizeLeft = makeResizeHandler("left");
const handleResizeRight = makeResizeHandler("right");
const handleResizeNW = makeResizeHandler("nw");
const handleResizeNE = makeResizeHandler("ne");
const handleResizeSW = makeResizeHandler("sw");
const handleResizeSE = makeResizeHandler("se");
// zone position drag — pending mode 에서 zone body (handle 외 영역)
// mousedown drag 로 x/y 이동. drag vs click 구분 :
// 5px 미만 이동 = click → onZoneClick
// 5px 이상 = drag → onZoneResize 로 x/y 변경
const handleZoneMouseDown = (
ev: React.MouseEvent<HTMLDivElement>
) => {
ev.stopPropagation();
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...localGeom };
const slideBodyWidthPx = canDrag
? W_SCALED * measuredSlideBody!.w
: 1;
const slideBodyHeightPx = canDrag
? H_SCALED * measuredSlideBody!.h
: 1;
let dragged = false;
// 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 && crossedDragThreshold(dxPx, dyPx)) {
dragged = true;
}
if (dragged) {
const { x: newX, y: newY } = clampZoneMove(
startGeom,
dxPx,
dyPx,
slideBodyWidthPx,
slideBodyHeightPx
);
onZoneResize!({
[zone.zone_id]: {
x: newX,
y: newY,
w: startGeom.w,
h: startGeom.h,
},
});
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
if (!dragged) {
// 단순 click 으로 처리 — onZoneClick.
onZoneClick?.(zone.id);
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
// Step A : 사용자가 다른 frame 을 골랐을 때 그 preview 를 zone 안에
// 반투명으로 미리보기. final.html (iframe) 의 default frame 과 다를 때만.
// override 없거나 default 와 같으면 미리보기 X (iframe 과 중복).
const region = zone.internal_regions[0];
const defaultFrameId = region?.frame_match_strategy.frame_id;
const overrideFrameId = region?.id
? userSelection.overrides.zone_frames?.[region.id]
: undefined;
const previewFrameId =
overrideFrameId && overrideFrameId !== defaultFrameId
? overrideFrameId
: null;
const previewCandidate = previewFrameId
? region?.frame_candidates?.find((c) => c.id === previewFrameId)
: null;
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
// IMP-11 u4: active frame lookup — distinct axis from preview.
// preview is shown only when override differs from default; active is
// always defined as override-if-present-else-default. Used by u5 to
// compare the active frame's catalog min_height_px against zone height.
const activeFrameId = overrideFrameId ?? defaultFrameId;
const activeCandidate = activeFrameId
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
: undefined;
// IMP-11 u5: catalog min_height_px violation hint. height is already
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
// not re-multiplied (double-apply would shrink the comparison value).
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
const minHeightPx = activeCandidate?.minHeightPx ?? null;
const belowMinHeight =
isPendingLayout &&
minHeightPx != null &&
zoneHeightPx != null &&
zoneHeightPx < minHeightPx;
return (
<div
key={zone.id}
role="button"
tabIndex={0}
onMouseDown={handleZoneMouseDown}
onDragEnter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
// 브라우저는 custom dataTransfer type 을 lowercase 강제 — 양쪽 다 check.
const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase());
if (types.includes("sectionid") || types.includes("section-id")) {
setDragOverZoneId(zone.id);
}
}}
onDragOver={(ev) => {
// drop target 이려면 onDragOver 에서 preventDefault 필수 — type check 없이 무조건 허용.
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}}
onDragLeave={(ev) => {
// 자식으로 들어가면서 발생하는 leave 무시 — 실제 leave 만 처리.
if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
setDragOverZoneId((p) => (p === zone.id ? null : p));
}}
onDrop={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDragOverZoneId(null);
// setData 했던 모든 type 을 시도 — case 변환 / 다른 키 fallback.
const sectionId =
ev.dataTransfer.getData("sectionId") ||
ev.dataTransfer.getData("section-id") ||
ev.dataTransfer.getData("text/plain");
if (sectionId && onSectionDrop) {
onSectionDrop(sectionId, zone.zone_id);
}
}}
className={`absolute z-20 transition-all overflow-hidden ${
isEditMode
? "border border-dashed border-emerald-300/40 cursor-default"
: isPendingLayout
? "cursor-grab active:cursor-grabbing"
: "cursor-pointer"
} ${
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
? "border border-dashed border-slate-300/40 hover:border-blue-400 hover:bg-blue-500/5"
: ""
}`}
style={{
left: `${x * 100}%`,
top: `${y * 100}%`,
width: `${width * 100}%`,
height: `${height * 100}%`,
// 편집 모드 : zone overlay 자체는 클릭 통과 (텍스트 편집 우선),
// resize handle 만 그 안에서 pointer-events: auto.
pointerEvents: isEditMode ? "none" : "auto",
}}
title={
previewUrl
? `${sectionLabel} (zone: ${zone.zone_id}) → preview frame ${previewCandidate?.name} (재생성 시 적용)`
: `${sectionLabel} (zone: ${zone.zone_id}) — 클릭 시 frame 후보 / section 카드 drop 가능`
}
>
{/* Step A : 선택 frame preview 미리보기 (반투명, iframe 과 중복 X) */}
{previewUrl && (
<>
<img
src={previewUrl}
alt={`${zone.zone_id} preview`}
className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none"
/>
{/* "preview" 배지 — 우상단 */}
<span className="absolute top-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-amber-500 text-white shadow">
preview
</span>
</>
)}
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
is below the active frame's catalog min_height_px. Visual
hint only, no clamp/resize behavior change. */}
{belowMinHeight && minHeightPx != null && (
<>
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
min H {minHeightPx}px
</span>
</>
)}
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
부 라벨 = backend zone position (top, bottom, primary). */}
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
<span
className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${
isSelected
? "bg-blue-600 text-white"
: isDragOver
? "bg-emerald-600 text-white"
: "bg-slate-900/80 text-white"
}`}
>
{sectionLabel}
</span>
<span className="text-[8px] font-bold uppercase tracking-tighter px-1 py-0.5 rounded bg-white/80 text-slate-500">
{zone.zone_id}
</span>
</div>
{/* drop hint — drag over 시 가운데에 안내 */}
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-[12px] font-black uppercase tracking-wider px-3 py-1.5 rounded-full bg-emerald-500 text-white shadow-xl">
Drop to assign
</span>
</div>
)}
{/* 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 || isEditMode) && onZoneResize && (
<>
{/* top edge */}
<div
onMouseDown={handleResizeTop}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="상단 boundary 조절"
/>
{/* bottom edge */}
<div
onMouseDown={handleResizeBottom}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="하단 boundary 조절"
/>
{/* left edge */}
<div
onMouseDown={handleResizeLeft}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌측 boundary 조절"
/>
{/* right edge */}
<div
onMouseDown={handleResizeRight}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="우측 boundary 조절"
/>
{/* corner NW (좌상단) */}
<div
onMouseDown={handleResizeNW}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌상단 동시 조절"
/>
{/* corner NE (우상단) */}
<div
onMouseDown={handleResizeNE}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="우상단 동시 조절"
/>
{/* corner SW (좌하단) */}
<div
onMouseDown={handleResizeSW}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="좌하단 동시 조절"
/>
{/* corner SE (우하단) */}
<div
onMouseDown={handleResizeSE}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="우하단 동시 조절"
/>
</>
)}
{/* 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>
)}
</div>
);
}