Phase Z pipeline is running...
Parsing MDX, matching frames, choosing layout, and rendering.
{
// 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 && (
{
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" }}
>
痍⑥냼
)}
{/* 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" ? "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 (
{
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}
);
})}
)}
{renderIframe && embeddedSrc && (
{/* ?? pendingLayout ??slide-body ?곸뿭 overlay ??
slide-base (title / divider / footer) ??final.html 洹몃?濡??좎?.
slide-body ?곸뿭?먮쭔 ??dashed overlay 源붿븘??湲곗〈 .zone ??媛由ш퀬
??layout zone ?덈궡 ?쒖떆. */}
{isPendingLayout && (
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 || 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
) => {
// 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
) => {
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 (
{
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 && (
<>
{/* "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 -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 */}
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 */}
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 */}
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 (醫뚯긽?? */}
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 (?곗긽?? */}
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 (醫뚰븯?? */}
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 (?고븯?? */}
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 && (
<>
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"
/>
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"
/>
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"
/>
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. */}
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"
/>
>
)}
);
})}
{/* 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??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
,
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 (
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 = 鍮꾩쑉 ?댁젣)"
: "?대┃?섏뿬 ?좏깮"
}
>
IMG
{isSelected && (
<>
{/* edges */}
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="?곷떒"
/>
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="?섎떒"
/>
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="醫뚯륫"
/>
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 */}
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 = 鍮꾩쑉 ?댁젣)"
/>
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 = 鍮꾩쑉 ?댁젣)"
/>
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 = 鍮꾩쑉 ?댁젣)"
/>
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 = 鍮꾩쑉 ?댁젣)"
/>
>
)}
);
}
)}
)}
);
}