/**
* 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 (
);
})}
)}
{/* ── pendingLayout 시 slide-body 영역 overlay ──
slide-base (title / divider / footer) 는 final.html 그대로 유지.
slide-body 영역에만 흰 dashed overlay 깔아서 기존 .zone 들 가리고
새 layout zone 안내 표시. */}
{isPendingLayout && measuredSlideBody && (
Pending Body Layout
{pendingLayoutId}
1. section 카드 → zone drop · 2. zone 클릭 → frame 선택 · 3. 재생성
)}
{/* ── 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) => {
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
if ((!isPendingLayout && !editGates.zoneGestures) || !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
) => {
ev.stopPropagation();
// IMP-90 u12: zone drag is image-zone-mode-only (text /
// structure suppress canDrag; non-zoneGestures click still
// triggers onZoneClick via the !dragged branch on mouse-up).
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && 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 (
{
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 && (
<>
{/* "preview" 배지 — 우상단 */}
preview
>
)}
{/* 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 && (
<>
min H {minHeightPx}px
>
)}
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
부 라벨 = backend zone position (top, bottom, primary). */}
{sectionLabel}
{zone.zone_id}
{/* drop hint — drag over 시 가운데에 안내 */}
{isDragOver && (
Drop to assign
)}
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
affordance 미노출 (editGates.zoneGestures = image-zone only).
edge handle (top/bottom/left/right) : 한 boundary 이동
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
<>
{/* top edge */}
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.
IMP-90 u12: image-zone-mode-only — text / structure 모드는
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<>
);
})}
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
hide). Renders only in `editMode === "structure"` over each
measured zone, positioned at the zone's top-right inside the
slide-absolute coord space. Slot keys come from u14 iframe
traversal (`slotKeysByZone`). Mutations emit through
onStructureEdit; u15 will debounce + PUT. */}
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
slidePlan?.zones.map((zone) => {
const m = measuredZones[zone.zone_id];
if (!m) return null;
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
const current = structureOverrides?.[zone.zone_id];
return (
);
})}
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
(iframe-measured baseline). pendingLayout suppresses the image
overlay so zone editing and image editing never compete for the
same pointer events.
For every stamped user-content image we render a transparent
wrapper at the image's slide-absolute coords. Wrapper picks up
the body-drag gesture (move the image without resizing). When
the image is the `selectedImageId` we additionally render 8
resize handles. Aspect ratio is LOCKED on corner drags by
default; holding Shift during the drag unlocks it (matches the
issue contract "corner_resize_ratio_default_locked_shift_unlock").
Coordinate space: slide-absolute percent (0–100) throughout —
measured / persisted / emitted values share the same units as
the u7 CSS injector (`left/top/width/height: {value}%`) and the
u3 typed-client `ImageOverride` contract. CSS values are
written verbatim ({geom.x}%, no scale factor) and pixel deltas
from MouseEvent are converted to percent via
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
re-render produces identical geometry. IMP-51 (#79) u9 moved
the resize / move math to `clampImagePercentGeometry` in
`slideCanvasDragMath.ts` so the boundary contract Codex #16
verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */}
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
structure 모드에서는 image drag/resize affordance 미노출
(editGates.imageOverlay = false). pendingLayout 도 동일하게
suppress (computeEditModeGates 가 모두 false 반환). */}
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
([imageId]) => {
const persisted = imageOverrides?.[imageId];
const measured = measuredImages[imageId];
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
const geom = persisted ?? measured;
if (!geom) return null;
const isSelected = selectedImageId === imageId;
const beginDrag = (
ev: React.MouseEvent,
direction: ImageDragDirection
) => {
ev.preventDefault();
ev.stopPropagation();
setSelectedImageId(imageId);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...geom };
// 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
// 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const isCorner =
direction === "nw" ||
direction === "ne" ||
direction === "sw" ||
direction === "se";
const onMove = (mv: MouseEvent) => {
// Convert pixel delta on the on-screen scaled slide
// back into percent-of-slide so all downstream math
// shares the persisted axis's coord space. W_SCALED /
// H_SCALED already include the wrapper scale factor,
// so dividing then multiplying by 100 gives a stable
// value regardless of viewport zoom.
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
// IMP-51 (#79) u9 — boundary contract lives in the
// pure helper so vitest can verify it directly.
// Aspect lock is default on for corner handles and
// released when Shift is held.
const aspectLocked = isCorner && !mv.shiftKey;
const next = clampImagePercentGeometry(
startGeom,
dx,
dy,
direction,
aspectLocked,
IMAGE_RESIZE_MIN_SIZE_PERCENT,
);
onImageResize(imageId, next);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
return (