/** * 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 ) => 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(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 >({}); // 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(null); // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. // pendingLayout 과 배타적 (충돌 방지). const [isEditMode, setIsEditMode] = useState(false); const iframeRef = useRef(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 (
{isEmpty && (

왼쪽 패널에서 MDX 파일을 업로드하세요.

업로드 후 하단 "슬라이드 플랜 생성하기" 버튼을 눌러주세요.

)} {isPipelineRunning && (

Phase Z 파이프라인 실행 중...

MDX 분석 → V4 매칭 → 레이아웃 / 프레임 결정 → 렌더

)} {showSlideBox && (
{ // 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 && ( )} {/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만. 글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */} {!isPendingLayout && finalHtmlUrl && ( )}