Files
C.E.L_Slide_test2/Front/client/src/components/SlideCanvas.tsx
T
kmlee1andClaude Opus 4.8 b836e79ee1 wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:03:42 +09:00

1457 lines
71 KiB
TypeScript

/**
* SlideCanvas ??Phase Z ?ㅼ젣 寃곌낵 (final.html) iframe ?쒖떆.
*
* ??븷 (Phase 1 ??MDX upload ???뚯씠?꾨씪???ㅽ뻾 ??寃곌낵 ?쒖떆):
* - MDX ?낅줈????server 媛€ Phase Z ?ㅽ뻾
* - run_id ??final.html ??iframe ?쇰줈 ?쒖떆
* - ?쒕??덉씠??/ mock ?щ씪?대뱶 layout ?뚮뜑 X
*
* ?댁쟾 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
* Phase Z Engine header / Summary footer) 紐⑤몢 ?쒓굅 ??frontend 媛€ ?щ씪?대뱶瑜?*留뚮뱾吏€ ?딆쓬*,
* backend 寃곌낵瑜?*?쎄린留???.
*
* Layout / zone / region / frame 蹂€寃?UI ??醫뚯슦 ?⑤꼸 (LayoutPanel / FramePanel)
* ?먯꽌 ?섑뻾. 蹂?而댄룷?뚰듃??*寃곌낵 viewer*.
*/
import { useRef, useEffect, useState } from "react";
import { Loader2, Upload as UploadIcon } from "lucide-react";
import type {
SlidePlan,
UserSelection,
NormalizedContent,
} from "../types/designAgent";
import {
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<string, { x: number; y: number; w: number; h: number }>
) => void;
/** IMP-51 (#79) u8 ??persisted slide-absolute image geometries
* (image_id ??{x,y,w,h} as percent of 1280횞720, range 0??00). 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??00 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<EditMode> = ["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<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);
// IMP-51 (#79) u8 ??measured user-content image bboxes inside iframe
// (slide-absolute percent of 1280횞720, range 0??00). 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<string, { x: number; y: number; w: number; h: number }>
>({});
// 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<string | null>(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<EditMode>("off");
const isEditMode = editMode !== "off";
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
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;
// 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<HTMLImageElement>(
'.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<string, string[]>
>({});
useEffect(() => {
if (editMode !== "structure" || isPendingLayout) {
setSlotKeysByZone({});
return;
}
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
const next: Record<string, string[]> = {};
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
if (!zoneId) return;
const seen = new Set<string>();
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]);
// #98 Task 4 ??reset iframe-derived UI state whenever a render is replaced,
// cleared, or hidden by a new pipeline run. This prevents stale zone
// measurements / edit overlays from lingering over the next final.html.
useEffect(() => {
setMeasuredZones({});
setMeasuredSlideBody(null);
setMeasuredImages({});
setSelectedImageId(null);
setDragOverZoneId(null);
setSlotKeysByZone({});
setEditMode("off");
}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);
// 16:9 鍮꾩쑉 ?좎??섎ʼn 而⑦뀒?대꼫???듭㎏濡?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;
const hasNoCurrentRender =
!finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout;
// ?щ씪?대뱶 諛뺤뒪 ?쒖떆 議곌굔 ??final.html ?덇굅??pendingLayout 紐⑤뱶.
// Task 13b: pending layout is a draft canvas, not a final.html preview.
// Hide the iframe entirely while pending so the prior committed render
// cannot visually overlap the pending zones.
const renderIframe = !!finalHtmlUrl && !isPendingLayout;
const showSlideBox = (renderIframe || isPendingLayout) && !isPipelineRunning;
const pendingSlideBody = measuredSlideBody ?? { x: 0, y: 0, w: 1, h: 1 };
// IMP-14 (Step 13 A-4) ??backend slide_base.html 媛€ embedded vs standalone CSS
// contract 瑜?`?embedded=1` query 濡??뚯쑀. 湲곗〈 query string 蹂댁〈?섎㈃??flag 留?異붽?.
const embeddedSrc = renderIframe && finalHtmlUrl
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
: undefined;
// wrapper ??scaled ?ш린瑜?媛€吏€誘€濡?layout ??fit. ?덉쓽 ?щ씪?대뱶??1280횞720 ?쇰줈
// top-left origin scale ??wrapper ?덉뿉 ?뺥솗??留욎땄.
const W_SCALED = SLIDE_W * scale;
const H_SCALED = SLIDE_H * scale;
return (
<div
ref={containerRef}
className="flex-1 flex items-center justify-center bg-slate-100 p-4 relative overflow-hidden"
>
{isEmpty && (
<div className="text-center text-slate-400 select-none">
<UploadIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm font-medium">Upload an MDX file from the left panel.</p>
<p className="text-xs text-slate-300 mt-1">
After upload, run the slide generation action from the footer.
</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 pipeline is running...</p>
<p className="text-xs text-slate-400 mt-1">
Parsing MDX, matching frames, choosing layout, and rendering.
</p>
</div>
)}
{hasNoCurrentRender && (
<div className="text-center text-slate-500 select-none max-w-sm">
<p className="text-sm font-bold">No current render</p>
<p className="text-xs text-slate-400 mt-1">
Run the pipeline to display a fresh final.html result.
</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} - assign sections, choose frames, then regenerate`
: "Open layout panel"
}
>
{/* 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>
)}
{/* 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 && (
<div
data-testid="edit-mode-toolbar"
className="absolute top-2 right-2 z-30 flex gap-1"
style={{ pointerEvents: "auto" }}
>
{EDIT_MODES.map((mode) => {
const active = editMode === mode;
const label =
mode === "text" ? "Text" : mode === "structure" ? "Structure" : "Image/Zone";
const title =
mode === "text"
? "Edit text in the rendered slide"
: mode === "structure"
? "Edit slot order and visibility"
: "Move or resize images and zones";
return (
<button
key={mode}
type="button"
data-testid={`edit-mode-${mode}`}
aria-pressed={active}
onClick={(e) => {
e.stopPropagation();
setEditMode((prev) => nextEditMode(prev, mode));
}}
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
active
? "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"
}`}
title={title}
>
{label}
</button>
);
})}
</div>
)}
<div
style={{
width: SLIDE_W,
height: SLIDE_H,
transform: `scale(${scale})`,
transformOrigin: "top left",
position: "absolute",
top: 0,
left: 0,
pointerEvents: "none",
}}
>
{renderIframe && embeddedSrc && (
<iframe
key={embeddedSrc}
ref={iframeRef}
src={embeddedSrc}
title="Phase Z ?뚮뜑 寃곌낵"
className="w-full h-full border-0 block"
scrolling="no"
sandbox="allow-same-origin allow-scripts"
// IMP-90 (#90) u12 ??iframe pointer-events gate. 'text' needs
// pe:auto so the user can click into text fields; 'image-zone'
// needs pe:auto so user-content image clicks can reach the
// in-iframe click handler that drives `selectedImageId`.
// 'structure' and 'off' keep pe:none ??structure has no
// in-iframe gesture (u14 will overlay React-side controls).
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
onLoad={(e) => {
// IMP-14 (Step 13 A-4) ??embedded vs standalone CSS reset ?€ backend
// slide_base.html 媛€ `?embedded=1` query 濡??뚯쑀. frontend 媛€ ???댁긽
// reset CSS 瑜?contentDocument ??inject ?섏? ?딆쓬. embedded query 媛€
// backend auto-mode detection script 瑜?trigger ?댁꽌 html.embedded
// class 瑜?遺숈씠怨?standalone-only body 洹쒖튃??reset.
try {
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return;
// ?€?€ 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,
});
}
// ?€?€ IMP-51 (#79) u8 ??user-content image bbox 痢≪젙 ?€?€
// u4 stamper 媛€ 遺€李⑺븳 data-image-id 媛€ ?덈뒗 img 留??≪쓬
// (decorative / frame img ?쒖쇅). 痢≪젙 寃곌낵??1280횞720 湲곗?
// ?щ씪?대뱶-?덈? percent (0??00) ??image_overrides axis (u3
// ?€??+ u7 CSS `left/top/width/height: {value}%` 二쇱엯) ?€
// ?숈씪??醫뚰몴怨꾨씪??痢≪젙 / ?곴뎄 ?€??/ emit 媛€ 1:1 留ㅼ묶??
const imageEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]'
);
const measuredImg: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {};
imageEls.forEach((imgEl) => {
const id = imgEl.dataset.imageId;
if (!id) return;
const r = imgEl.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
measuredImg[id] = {
x: (r.left / SLIDE_W) * 100,
y: (r.top / SLIDE_H) * 100,
w: (r.width / SLIDE_W) * 100,
h: (r.height / SLIDE_H) * 100,
};
});
setMeasuredImages(measuredImg);
} 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 && (
<div
className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
style={{
left: `${pendingSlideBody.x * 100}%`,
top: `${pendingSlideBody.y * 100}%`,
width: `${pendingSlideBody.w * 100}%`,
height: `${pendingSlideBody.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 &nbsp;쨌&nbsp; 2. zone ?대┃ ??frame ?좏깮 &nbsp;쨌&nbsp; 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 || isPendingLayout)) {
// ?ъ슜??override ??slide-body 湲곗?
x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w;
y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h;
width = overrideGeom.w * pendingSlideBody.w;
height = overrideGeom.h * pendingSlideBody.h;
} else if (isPendingLayout) {
x = pendingSlideBody.x + zone.position.x * pendingSlideBody.w;
y = pendingSlideBody.y + zone.position.y * pendingSlideBody.h;
width = zone.position.width * pendingSlideBody.w;
height = zone.position.height * pendingSlideBody.h;
} else {
const measured = measuredZones[zone.zone_id];
x = measured?.x ?? zone.position.x;
y = measured?.y ?? zone.position.y;
width = measured?.w ?? zone.position.width;
height = measured?.h ?? zone.position.height;
}
// Step C : pending mode ??zone overlay resize handles ??independent 紐⑤뜽.
// 媛?zone ???낅┰ 諛뺤뒪 ???몄젒 zone ?곹뼢 X. ?먯쑀 諛곗튂 (gap / 寃뱀묠 ?ъ슜??梨낆엫).
// boundary drag 紐⑤뜽 (?몄젒 ?먮룞 議곗젙) ?€ grid 媛숈? 臾띠쓬 layout ???곹빀?쒕뜲,
// ?ъ슜???섎룄 = zone position drag 源뚯? ?먯쑀濡?쾶 ??independent 媛€ ?먯뿰.
type ResizeDir =
| "top"
| "bottom"
| "left"
| "right"
| "nw"
| "ne"
| "sw"
| "se";
const makeResizeHandler = (
direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize ??pendingLayout OR 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;
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 * pendingSlideBody.w;
const slideBodyHeightPx = H_SCALED * pendingSlideBody.h;
const minSize = 0.05;
const affectsLeft = direction === "left" || direction === "nw" || direction === "sw";
const affectsRight = direction === "right" || direction === "ne" || direction === "se";
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
// 2026-05-22 demo hot-fix ??iframe ??留덉슦??媛€濡쒖콈??mouseup leak ?쇱뼱??
// (?몄쭛 紐⑤뱶?먯꽌 iframe pointerEvents=auto). drag ?숈븞 iframe 媛뺤젣 none.
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const onMove = (mv: MouseEvent) => {
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
let { x, y, w, h } = startGeom;
if (affectsRight) {
w = Math.max(minSize, Math.min(1 - startGeom.x, startGeom.w + dx));
}
if (affectsBottom) {
h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
}
if (affectsLeft) {
// 醫뚯륫 寃쎄퀎 ?대룞 ??w 以꾩씠怨?x ?섎┝ (?먮뒗 諛섎?)
const newW = Math.max(minSize, startGeom.w - dx);
x = Math.max(0, startGeom.x + (startGeom.w - newW));
w = newW;
}
if (affectsTop) {
const newH = Math.max(minSize, startGeom.h - dy);
y = Math.max(0, startGeom.y + (startGeom.h - newH));
h = newH;
}
onZoneResize({ [zone.zone_id]: { x, y, w, h } });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const handleResizeTop = makeResizeHandler("top");
const handleResizeBottom = makeResizeHandler("bottom");
const handleResizeLeft = makeResizeHandler("left");
const handleResizeRight = makeResizeHandler("right");
const handleResizeNW = makeResizeHandler("nw");
const handleResizeNE = makeResizeHandler("ne");
const handleResizeSW = makeResizeHandler("sw");
const handleResizeSE = makeResizeHandler("se");
// zone position drag ??pending mode ?먯꽌 zone body (handle ???곸뿭)
// mousedown drag 濡?x/y ?대룞. drag vs click 援щ텇 :
// 5px 誘몃쭔 ?대룞 = click ??onZoneClick
// 5px ?댁긽 = drag ??onZoneResize 濡?x/y 蹂€寃?
const handleZoneMouseDown = (
ev: React.MouseEvent<HTMLDivElement>
) => {
ev.stopPropagation();
// 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) && onZoneResize);
const startMouseX = ev.clientX;
const startMouseY = ev.clientY;
const startGeom = { ...localGeom };
const slideBodyWidthPx = canDrag
? W_SCALED * pendingSlideBody.w
: 1;
const slideBodyHeightPx = canDrag
? H_SCALED * pendingSlideBody.h
: 1;
let dragged = false;
// 2026-05-22 demo hot-fix ??same iframe pointer-events fix as makeResizeHandler.
const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none";
const onMove = (mv: MouseEvent) => {
if (!canDrag) return;
const dxPx = mv.clientX - startMouseX;
const dyPx = mv.clientY - startMouseY;
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
dragged = true;
}
if (dragged) {
const { x: newX, y: newY } = clampZoneMove(
startGeom,
dxPx,
dyPx,
slideBodyWidthPx,
slideBodyHeightPx
);
onZoneResize!({
[zone.zone_id]: {
x: newX,
y: newY,
w: startGeom.w,
h: startGeom.h,
},
});
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
if (!dragged) {
// ?⑥닚 click ?쇰줈 泥섎━ ??onZoneClick.
onZoneClick?.(zone.id);
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
// Step A : ?ъ슜?먭? ?ㅻⅨ frame ??怨⑤옄????洹?preview 瑜?zone ?덉뿉
// 諛섑닾紐낆쑝濡?誘몃━蹂닿린. final.html (iframe) ??default frame 怨??ㅻ? ?뚮쭔.
// override ?녾굅??default ?€ 媛숈쑝硫?誘몃━蹂닿린 X (iframe 怨?以묐났).
const region = zone.internal_regions[0];
const defaultFrameId = region?.frame_match_strategy.frame_id;
const overrideFrameId = region?.id
? userSelection.overrides.zone_frames?.[region.id]
: undefined;
const previewFrameId =
overrideFrameId && overrideFrameId !== defaultFrameId
? overrideFrameId
: null;
const previewCandidate = previewFrameId
? region?.frame_candidates?.find((c) => c.id === previewFrameId)
: null;
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
// IMP-11 u4: active frame lookup ??distinct axis from preview.
// preview is shown only when override differs from default; active is
// always defined as override-if-present-else-default. Used by u5 to
// compare the active frame's catalog min_height_px against zone height.
const activeFrameId = overrideFrameId ?? defaultFrameId;
const activeCandidate = activeFrameId
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
: undefined;
// IMP-11 u5: catalog min_height_px violation hint. height is already
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
// not re-multiplied (double-apply would shrink the comparison value).
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
const minHeightPx = activeCandidate?.minHeightPx ?? null;
const belowMinHeight =
isPendingLayout &&
minHeightPx != null &&
zoneHeightPx != null &&
zoneHeightPx < minHeightPx;
return (
<div
key={zone.id}
role="button"
tabIndex={0}
onMouseDown={handleZoneMouseDown}
onDragEnter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
// 釉뚮씪?곗???custom dataTransfer type ??lowercase 媛뺤젣 ???묒そ ??check.
const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase());
if (types.includes("sectionid") || types.includes("section-id")) {
setDragOverZoneId(zone.id);
}
}}
onDragOver={(ev) => {
// drop target ?대젮硫?onDragOver ?먯꽌 preventDefault ?꾩닔 ??type check ?놁씠 臾댁“嫄??덉슜.
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}}
onDragLeave={(ev) => {
// ?먯떇?쇰줈 ?ㅼ뼱媛€硫댁꽌 諛쒖깮?섎뒗 leave 臾댁떆 ???ㅼ젣 leave 留?泥섎━.
if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
setDragOverZoneId((p) => (p === zone.id ? null : p));
}}
onDrop={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDragOverZoneId(null);
// setData ?덈뜕 紐⑤뱺 type ???쒕룄 ??case 蹂€??/ ?ㅻⅨ ??fallback.
const sectionId =
ev.dataTransfer.getData("sectionId") ||
ev.dataTransfer.getData("section-id") ||
ev.dataTransfer.getData("text/plain");
if (sectionId && onSectionDrop) {
onSectionDrop(sectionId, zone.zone_id);
}
}}
className={`absolute z-20 transition-all overflow-hidden ${
isEditMode
? "border border-dashed border-emerald-300/40 cursor-default"
: isPendingLayout
? "cursor-grab active:cursor-grabbing"
: "cursor-pointer"
} ${
isDragOver
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
: isSelected && isEditMode
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
: isSelected && !isEditMode
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
: !isEditMode
? "border border-dashed border-slate-300/40 hover:border-blue-400 hover:bg-blue-500/5"
: ""
}`}
style={{
left: `${x * 100}%`,
top: `${y * 100}%`,
width: `${width * 100}%`,
height: `${height * 100}%`,
// ?몄쭛 紐⑤뱶 : zone overlay ?먯껜???대┃ ?듦낵 (?띿뒪???몄쭛 ?곗꽑),
// resize handle 留?洹??덉뿉??pointer-events: auto.
pointerEvents: isEditMode ? "none" : "auto",
}}
title={
previewUrl
? `${sectionLabel} (zone: ${zone.zone_id}) - preview frame ${previewCandidate?.name}`
: `${sectionLabel} (zone: ${zone.zone_id}) - open frame panel or drop a section`
}
>
{/* Step A : ?좏깮 frame preview 誘몃━蹂닿린 (諛섑닾紐? iframe 怨?以묐났 X) */}
{previewUrl && (
<>
<img
src={previewUrl}
alt={`${zone.zone_id} preview`}
className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none"
/>
{/* "preview" 諛곗? ???곗긽??*/}
<span className="absolute top-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-amber-500 text-white shadow">
preview
</span>
</>
)}
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
is below the active frame's catalog min_height_px. Visual
hint only, no clamp/resize behavior change. */}
{belowMinHeight && minHeightPx != null && (
<>
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
min H {minHeightPx}px
</span>
</>
)}
{/* zone ?쇰꺼 ??醫뚯긽?? 二??쇰꺼 = section ids (S1, S1+S2),
遺€ ?쇰꺼 = backend zone position (top, bottom, primary). */}
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
<span
className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${
isSelected
? "bg-blue-600 text-white"
: isDragOver
? "bg-emerald-600 text-white"
: "bg-slate-900/80 text-white"
}`}
>
{sectionLabel}
</span>
<span className="text-[8px] font-bold uppercase tracking-tighter px-1 py-0.5 rounded bg-white/80 text-slate-500">
{zone.zone_id}
</span>
</div>
{/* drop hint ??drag over ??媛€?대뜲???덈궡 */}
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-[12px] font-black uppercase tracking-wider px-3 py-1.5 rounded-full bg-emerald-500 text-white shadow-xl">
Drop to assign
</span>
</div>
)}
{/* Step C : zone resize handles ??8 諛⑺뼢. pendingLayout OR 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 */}
<div
onMouseDown={handleResizeTop}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곷떒 boundary 議곗젅"
/>
{/* bottom edge */}
<div
onMouseDown={handleResizeBottom}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="?섎떒 boundary 議곗젅"
/>
{/* left edge */}
<div
onMouseDown={handleResizeLeft}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚯륫 boundary 議곗젅"
/>
{/* right edge */}
<div
onMouseDown={handleResizeRight}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곗륫 boundary 議곗젅"
/>
{/* corner NW (醫뚯긽?? */}
<div
onMouseDown={handleResizeNW}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚯긽???숈떆 議곗젅"
/>
{/* corner NE (?곗긽?? */}
<div
onMouseDown={handleResizeNE}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곗긽???숈떆 議곗젅"
/>
{/* corner SW (醫뚰븯?? */}
<div
onMouseDown={handleResizeSW}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚰븯???숈떆 議곗젅"
/>
{/* corner SE (?고븯?? */}
<div
onMouseDown={handleResizeSE}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="?고븯???숈떆 議곗젅"
/>
</>
)}
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
wrapper sets pointerEvents:none in edit mode (see above) to
preserve iframe text-edit clicks (A8 guardrail), so the
wrapper-level handleZoneMouseDown is unreachable in edit mode.
These 4 perimeter strips + top-left grip provide a separate
pointer-event surface routing into handleZoneMouseDown.
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
gesture wins in overlap regions, and ABOVE the iframe so the
strips intercept the perimeter while the un-covered iframe
interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode.
IMP-90 u12: image-zone-mode-only ??text / structure 紐⑤뱶??
zone drag ????(editGates.zoneGestures = false ??紐⑤뱶 紐⑤몢). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="Move zone"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="Move zone"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="Move zone"
/>
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="Move zone"
/>
{/* visible grip affordance ??placed below the section label
(top-1 left-1 container) so the two don't overlap. */}
<div
onMouseDown={handleZoneMouseDown}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
style={{ pointerEvents: "auto", zIndex: 25 }}
title="Drag to move zone"
/>
</>
)}
</div>
);
})}
{/* 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 (
<div
key={`struct-${zone.id}`}
className="absolute z-30"
style={{
left: m.x * W_SCALED,
top: m.y * H_SCALED,
width: m.w * W_SCALED,
pointerEvents: "none",
}}
>
<StructureEditOverlay
zoneId={zone.zone_id}
slotKeys={slotKeys}
current={current}
onChange={onStructureEdit}
/>
</div>
);
})}
{/* ?€?€ 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??00) 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<HTMLDivElement>,
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 (
<div
key={`img-overlay-${imageId}`}
role="button"
tabIndex={0}
data-image-overlay-id={imageId}
onMouseDown={(ev) => beginDrag(ev, "move")}
className={`absolute z-30 ${
isSelected
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
} cursor-grab active:cursor-grabbing`}
style={{
left: `${geom.x}%`,
top: `${geom.y}%`,
width: `${geom.w}%`,
height: `${geom.h}%`,
pointerEvents: "auto",
}}
title={
isSelected
? "?대?吏€ ?대룞 ???쒕옒洹?/ 紐⑥꽌由??몃뱾 = ?ш린 (Shift = 鍮꾩쑉 ?댁젣)"
: "?대┃?섏뿬 ?좏깮"
}
>
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
IMG
</span>
{isSelected && (
<>
{/* edges */}
<div
onMouseDown={(ev) => beginDrag(ev, "top")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곷떒"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "bottom")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="?섎떒"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "left")}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚯륫"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "right")}
onClick={(ev) => ev.stopPropagation()}
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곗륫"
/>
{/* corners ??aspect locked by default, Shift unlocks */}
<div
onMouseDown={(ev) => beginDrag(ev, "nw")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚯긽??(Shift = 鍮꾩쑉 ?댁젣)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "ne")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="?곗긽??(Shift = 鍮꾩쑉 ?댁젣)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "sw")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="醫뚰븯??(Shift = 鍮꾩쑉 ?댁젣)"
/>
<div
onMouseDown={(ev) => beginDrag(ev, "se")}
onClick={(ev) => ev.stopPropagation()}
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
style={{ pointerEvents: "auto" }}
title="?고븯??(Shift = 鍮꾩쑉 ?댁젣)"
/>
</>
)}
</div>
);
}
)}
</div>
)}
</div>
);
}