feat(frontend): add Front/ — Vite/React frontend with backend pipeline integration
Mirror of design_agent_front/design-agent/ for shipping alongside backend.
Vite plugin (vitePluginPhaseZApi) endpoints :
- POST /api/run — spawn `python -m src.phase_z2_pipeline` with overrides
- GET /api/sample-mdx?mdx=03/04/05 — fixed sample MDX
- GET /frame-preview/{n} — figma preview thumbnails
- GET /data/runs/{run_id}/{path} — pipeline artifacts (final.html, step*.json, ...)
Env toggle forward (보고용) :
PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT / PHASE_Z_MAX_RANK=32
Components :
- LeftMdxPanel (03/04/05 fix list + section tree)
- SlideCanvas (iframe + slideOverrideCss prop for inline CSS inject)
- FramePanel (label priority + confidence sort)
- LayoutPanel
README with mermaid diagrams covering the 5-step demo flow.
node_modules / dist / .manus-logs / .env excluded via .gitignore.
This commit is contained in:
805
Front/client/src/components/SlideCanvas.tsx
Normal file
805
Front/client/src/components/SlideCanvas.tsx
Normal file
@@ -0,0 +1,805 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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={finalHtmlUrl}
|
||||
title="Phase Z 렌더 결과"
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
|
||||
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
|
||||
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
|
||||
try {
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
if (!doc) return;
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = `
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 0 !important;
|
||||
height: 720px !important;
|
||||
width: 1280px !important;
|
||||
background: transparent !important;
|
||||
display: block !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.slide {
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
|
||||
// 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 모드에서만 — 첫 초안 (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;
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user