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>
899 lines
44 KiB
TypeScript
899 lines
44 KiB
TypeScript
/**
|
||
* 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 · 2. zone 클릭 → frame 선택 · 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>
|
||
);
|
||
}
|