/** * 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 { IMAGE_RESIZE_MIN_SIZE_PERCENT, clampImagePercentGeometry, clampZoneMove, crossedDragThreshold, type ImageDragDirection, } from "./slideCanvasDragMath"; import type { ImageOverridesOverride, StructureOverridesOverride, StructureOverridePerZone, } from "../services/userOverridesApi"; import StructureEditOverlay from "./StructureEditOverlay"; interface SlideCanvasProps { slidePlan: SlidePlan | null; normalizedContent: NormalizedContent | null; userSelection: UserSelection; /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */ finalHtmlUrl?: 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; /** IMP-51 (#79) u8 — persisted slide-absolute image geometries * (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors * the u3 typed-client `ImageOverride` contract and the u7 stamper that * emits CSS `left/top/width/height: {value}%`. Forward-compat optional; * u11 wires this from `userSelection.overrides.image_overrides`. When * present, SlideCanvas displays the persisted geometry instead of the * iframe-measured baseline. */ imageOverrides?: ImageOverridesOverride; /** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped * user-content image. Geometry is slide-absolute percent (0–100 of * 1280×720), matching the persisted axis schema (u3 typed client) and * the u7 CSS injection that writes the values directly into * `left/top/width/height: {value}%`. u10 wires this to a persistence * handler that updates `image_overrides` on user_overrides.json. */ onImageResize?: ( imageId: string, geometry: { x: number; y: number; w: number; h: number } ) => void; /** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */ onTextEdit?: (capture: TextEditCapture) => void; /** IMP-90 (#90) u14 — persisted structure overrides per zone * (slot_order + hidden_slots). When `editMode === "structure"` the * StructureEditOverlay reads from this to render the current state. */ structureOverrides?: StructureOverridesOverride; /** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a * slot in structure-mode. u15 will debounce + PUT to /api/user- * overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is * `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */ onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void; } const SLIDE_W = 1280; const SLIDE_H = 720; // IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single // `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface; // gesture gating (text contentEditable vs structure reorder vs image-zone // drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so // existing behavior is preserved byte-identical. u12 will discriminate the // gestures per mode (mutually exclusive). The 'off' state is the no-edit // baseline; 'image-zone' bundles image edit (#79) + zone resize (#81) // because both are pointer-driven canvas gestures on slide geometry. export type EditMode = "off" | "text" | "structure" | "image-zone"; export const EDIT_MODES: ReadonlyArray = ["text", "structure", "image-zone"]; /** Pure helper — given the current edit mode and the user's requested mode, * return the next mode. Clicking the active mode toggles back to 'off'; * clicking a different mode switches; explicit 'off' always exits. */ export function nextEditMode(current: EditMode, requested: EditMode): EditMode { if (requested === "off") return "off"; return current === requested ? "off" : requested; } // IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the // boolean gates that drive SlideCanvas's useEffect branches (designMode // + iframe-side image click listener) and JSX conditionals (iframe // pointer-events, zone resize/drag affordances, image overlay). The // mapping enforces the mutually-exclusive contract from the issue body: // text -> contentEditable + iframe pointer-events:auto only. // structure -> nothing here; u14 will plant the structure overlay. // image-zone -> zone resize/drag + image overlay; iframe pe:auto so // in-iframe user-content images can be click-selected. // off -> every gate false (baseline). // pendingLayout fully suppresses every gate — mirrors the existing // useEffect (line ~248) that forces editMode='off' on pendingLayout // entry. The helper still defensively returns all-false so a stray // pendingLayout=true with a non-'off' editMode never leaks gestures. export interface EditModeGates { textEditing: boolean; imageSelection: boolean; iframePointerAuto: boolean; zoneGestures: boolean; imageOverlay: boolean; } export function computeEditModeGates( editMode: EditMode, isPendingLayout: boolean ): EditModeGates { if (isPendingLayout) { return { textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false, }; } return { textEditing: editMode === "text", imageSelection: editMode === "image-zone", iframePointerAuto: editMode === "text" || editMode === "image-zone", zoneGestures: editMode === "image-zone", imageOverlay: editMode === "image-zone", }; } // IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout // target into (zoneId, textPath, value). data-text-path stamped by u8 at // Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped // targets return null so capture silently skips. u15 will debounce + PUT. export interface TextEditCaptureTarget { closest(selector: string): TextEditCaptureTarget | null; getAttribute(name: string): string | null; textContent: string | null; } export interface TextEditCapture { zoneId: string; textPath: string; value: string; } export function deriveTextEditCapture( target: TextEditCaptureTarget | null ): TextEditCapture | null { if (!target) return null; const lineEl = target.closest("[data-text-path]"); if (!lineEl) return null; const textPath = lineEl.getAttribute("data-text-path"); if (!textPath) return null; const zoneEl = lineEl.closest(".zone[data-zone-position]"); if (!zoneEl) return null; const zoneId = zoneEl.getAttribute("data-zone-position"); if (!zoneId) return null; return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() }; } export default function SlideCanvas({ slidePlan, userSelection, finalHtmlUrl, isPipelineRunning, isPendingLayout, pendingLayoutId, onCancelPendingLayout, onContentEdit, onZoneClick, onSlideClick, onSectionDrop, onZoneResize, imageOverrides, onImageResize, onTextEdit, structureOverrides, onStructureEdit, }: 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); // IMP-51 (#79) u8 — measured user-content image bboxes inside iframe // (slide-absolute percent of 1280×720, range 0–100). key = data-image-id // stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe // onLoad measure block alongside measuredZones / measuredSlideBody. // Units intentionally match the persisted `image_overrides` axis (u3 // typed client) and the u7 CSS injection so the overlay math has a // single coord space across measured/persisted/emitted values. Used as // the baseline geometry when no persisted override exists for that id; // `imageOverrides` prop (u11-fed) wins when present. const [measuredImages, setMeasuredImages] = useState< Record >({}); // IMP-51 (#79) u8 — currently selected user-content image id (= the one // whose drag/resize handles are shown). Set by the click-listener // installed inside the iframe contentDocument when edit mode is active. // Reset on finalHtmlUrl change and on edit-mode exit so stale ids never // leak across runs. const [selectedImageId, setSelectedImageId] = useState(null); // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. // pendingLayout 과 배타적 (충돌 방지). // IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The // `isEditMode` shim is kept ONLY for the pendingLayout coupling + // zone-wrapper visual cues (border / hover / selected styling) that // fire whenever any edit mode is active. u12 routes gesture-activating // gates through `editGates` so text / structure / image-zone gestures // are mutually exclusive. const [editMode, setEditMode] = useState("off"); const isEditMode = editMode !== "off"; const editGates = computeEditModeGates(editMode, !!isPendingLayout); 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; // IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per // finished line edit; u15 will debounce + PUT. let textEditCaptureHandler: ((e: Event) => void) | null = null; // IMP-51 (#79) u8 — user-content image click listeners installed // inside the iframe contentDocument. Tracked here so the cleanup // callback can remove them when edit mode exits (or iframe reloads). const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = []; // IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode // turns designMode on + makes the editable tags contentEditable. // The else branch tears the prior state down so leaving text mode // (to structure / image-zone / off) immediately disables in-place // text editing — required for mutual exclusivity vs the image-zone // overlay's drag/resize gestures (a contentEditable cursor would // otherwise be placed by every image click). if (editGates.textEditing) { 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); textEditCaptureHandler = (ev: Event) => { const cap = deriveTextEditCapture( ev.target as unknown as TextEditCaptureTarget | null ); if (cap) onTextEdit?.(cap); }; doc.addEventListener("focusout", textEditCaptureHandler); } else { doc.designMode = "off"; doc.querySelectorAll("[contenteditable]").forEach((el) => { (el as HTMLElement).removeAttribute("contenteditable"); }); } // IMP-90 (#90) u12 — image-selection gate: only the 'image-zone' // editMode wires the in-iframe user-content image click → selection. // Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py // (requires data-image-id which the stamper always emits). Decorative // / frame imgs lacking the role attribute are NOT clickable. The // else branch clears `selectedImageId` so the React-side overlay // never lingers on a non-image-zone edit mode. if (editGates.imageSelection) { const imgEls = doc.querySelectorAll( '.slide img[data-image-role="user-content"][data-image-id]' ); imgEls.forEach((imgEl) => { const imgId = imgEl.dataset.imageId; if (!imgId) return; const handler = (ev: Event) => { ev.stopPropagation(); ev.preventDefault(); setSelectedImageId(imgId); }; const prevCursor = imgEl.style.cursor; const prevOutline = imgEl.style.outline; imgEl.style.cursor = "pointer"; imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)"; imgEl.addEventListener("click", handler); imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline }); }); } else { setSelectedImageId(null); } return () => { if (inputHandler && doc) { doc.removeEventListener("input", inputHandler); } if (textEditCaptureHandler && doc) { doc.removeEventListener("focusout", textEditCaptureHandler); } imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => { el.removeEventListener("click", handler); el.style.cursor = prevCursor; el.style.outline = prevOutline; }); }; }, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]); // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지). useEffect(() => { if (isPendingLayout && isEditMode) setEditMode("off"); }, [isPendingLayout, isEditMode]); // IMP-90 (#90) u14 — discover slot keys per zone for the structure // overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"` // attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key // prefixes per `.zone[data-zone-position]` form the overlay's slot list. // Discovery runs only when entering structure mode (and resets on exit // or iframe reload) so off / text / image-zone modes never pay this // traversal cost. const [slotKeysByZone, setSlotKeysByZone] = useState< Record >({}); useEffect(() => { if (editMode !== "structure" || isPendingLayout) { setSlotKeysByZone({}); return; } const doc = iframeRef.current?.contentDocument; if (!doc) return; const next: Record = {}; doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => { const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position"); if (!zoneId) return; const seen = new Set(); const keys: string[] = []; zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => { const path = (lineEl as HTMLElement).getAttribute("data-text-path"); if (!path) return; const lastDot = path.lastIndexOf("."); const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path; if (slotKey && !seen.has(slotKey)) { seen.add(slotKey); keys.push(slotKey); } }); next[zoneId] = keys; }); setSlotKeysByZone(next); }, [editMode, isPendingLayout, finalHtmlUrl]); // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset. // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움. useEffect(() => { setMeasuredZones({}); setMeasuredSlideBody(null); // IMP-51 (#79) u8 — image measurements + selection are per-render; // drop both so the new iframe's onLoad starts clean. setMeasuredImages({}); setSelectedImageId(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 && ( )} {/* IMP-90 (#90) u11 — discriminated edit-mode toolbar. Replaces the prior single ✏ toggle. Three modes (text / structure / image-zone) are mutually exclusive; clicking the active mode toggles back to 'off'. Gesture gating per mode is u12 — u11 only plants the state + UI surface, so all three modes currently share the same `isEditMode` shim behavior. */} {!isPendingLayout && finalHtmlUrl && (
{EDIT_MODES.map((mode) => { const active = editMode === mode; const label = mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존"; const title = mode === "text" ? "텍스트 편집 — 텍스트 클릭하여 직접 수정" : mode === "structure" ? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)" : "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈"; return ( ); })}
)}