Files
C.E.L_Slide_test2/Front/client/src/components/SlideCanvas.tsx
kyeongmin 15ef7c65e9 fix(#75): IMP-47A mdx03 frontend execution stabilization (u1~u4)
u1: SlideCanvas iframe sandbox += allow-scripts (allow-same-origin preserved)
    → embedded-mode script in slide_base.html now applies html.embedded
    → standalone CSS reset deactivates inside iframe; no clipping
u2: designAgentApi.loadRun merges candidate_evidence + v4_all_judgments
    + v4_candidates via Map<template_id|id|frame_id> dedup,
    LABEL_PRIORITY (use_as_is<light_edit<restructure<reject) then
    confidence desc, capped TOP_N_FRAMES=6
u3: Home.handleGenerate useCallback deps = [uploadedFile, slidePlan,
    userSelection, pendingZones, pendingLayout] (5-tuple, stale-closure fix)
u4: tests/manual/imp47a_e2e.md — mdx03 manual e2e spec (5 axes)

Frontend-only. Backend src/ untouched. No template/catalog edits.
Determinism preserved (no LLM in frontend merge logic).
Baseline: pytest -q tests → 623 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:56:56 +09:00

830 lines
39 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";
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 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
// zone 자유 배치.
if (!isPendingLayout || !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";
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);
};
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 && 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;
const dragThresholdPx = 5;
const onMove = (mv: MouseEvent) => {
if (!canDrag) return;
const dxPx = mv.clientX - startMouseX;
const dyPx = mv.clientY - startMouseY;
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
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)
);
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 (!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-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 모드만 활성
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
edge handle (top/bottom/left/right) : 한 boundary 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{isPendingLayout && 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="우하단 동시 조절"
/>
</>
)}
</div>
);
})}
</div>
)}
</div>
);
}