fix(#12): SlideCanvas.tsx 한글 손상 복구 (mojibake 역변환 + 4da22ad 정상본 회수)

b836e79 스냅샷에서 UTF-8→CP949 오변환으로 한글 주석·UI 문자열 156줄 손상.
복구 방법:
- CP949 재인코딩→UTF-8 역변환 (ASCII 코드 줄 불변 보장)
- 소실 바이트('?' 잔여)가 있는 줄만 4da22ad 정상본에서 회수 (134줄 + 수작업 9줄)
- 순수 ASCII 줄 변조 0 검증 — b836e79의 기능 변경(pendingSlideBody 등) 전부 보존
- BOM 제거
검증: tsc --noEmit 0 에러, vitest 425/425 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:58:45 +09:00
co-authored by Claude Opus 4.8
parent 9a44abe84e
commit ad106578fe
+156 -156
View File
@@ -1,17 +1,17 @@
/**
* SlideCanvas ??Phase Z ?ㅼ젣 寃곌낵 (final.html) iframe ?쒖떆.
/**
* SlideCanvas Phase Z 실제 결과 (final.html) iframe 표시.
*
* ??븷 (Phase 1 ??MDX upload ???뚯씠?꾨씪???ㅽ뻾 ??寃곌낵 ?쒖떆):
* - MDX ?낅줈????server 媛€ Phase Z ?ㅽ뻾
* - run_id ??final.html ??iframe ?쇰줈 ?쒖떆
* - ?쒕??덉씠??/ mock ?щ씪?대뱶 layout ?뚮뜑 X
* 역할 (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 寃곌낵瑜?*?쎄린留???.
* 이전 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
* Phase Z Engine header / Summary footer) 모두 제거 — frontend 가 슬라이드를 *만들지 않음*,
* backend 결과를 *읽기만 함*.
*
* Layout / zone / region / frame 蹂€寃?UI ??醫뚯슦 ?⑤꼸 (LayoutPanel / FramePanel)
* ?먯꽌 ?섑뻾. 蹂?而댄룷?뚰듃??*寃곌낵 viewer*.
* Layout / zone / region / frame 변경 UI 는 좌우 패널 (LayoutPanel / FramePanel)
* 에서 수행. 본 컴포넌트는 *결과 viewer*.
*/
import { useRef, useEffect, useState } from "react";
@@ -39,19 +39,19 @@ interface SlideCanvasProps {
slidePlan: SlidePlan | null;
normalizedContent: NormalizedContent | null;
userSelection: UserSelection;
/** Phase Z 媛€ 留뚮뱺 final.html URL (iframe ?쇰줈 ?쒖떆). */
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
finalHtmlUrl?: string;
/** ?뚯씠?꾨씪???ㅽ뻾 以??쒖떆 (loading state). */
/** 파이프라인 실행 중 표시 (loading state). */
isPipelineRunning?: boolean;
/** Phase 2 : pending layout 紐⑤뱶 ??final.html iframe ?④린怨?鍮?layout zone 留??쒖떆. */
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
isPendingLayout?: boolean;
pendingLayoutId?: string | null;
onCancelPendingLayout?: () => void;
/** ?몄쭛 紐⑤뱶 ?띿뒪??蹂€寃?諛쒖깮 ????Home.tsx 媛€ hasPendingChanges ?몃━嫄? */
/** 편집 모드 텍스트 변경 발생 시 — Home.tsx hasPendingChanges 트리거. */
onContentEdit?: () => void;
/** Zone overlay ?대┃ ???곗륫 Frame ??쑝濡??꾪솚 + 洹?zone ??frame ?꾨낫 ?쒖떆. */
/** Zone overlay 클릭 — 우측 Frame 탭으로 전환 + zone frame 후보 표시. */
onZoneClick?: (zoneId: string) => void;
/** ?щ씪?대뱶 諛뺤뒪 (zone overlay ??鍮??곸뿭) ?대┃ ???곗륫 Layout ??쑝濡??꾪솚. */
/** 슬라이드 박스 (zone overlay 외 빈 영역) 클릭 — 우측 Layout 탭으로 전환. */
onSlideClick?: () => void;
onRegionClick?: (regionId: string) => void;
onFrameSelect?: (frameId: string) => void;
@@ -61,7 +61,7 @@ interface SlideCanvasProps {
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 1280720, range 0??00). Mirrors
* (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
@@ -70,7 +70,7 @@ interface SlideCanvasProps {
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
* 1280720), matching the persisted axis schema (u3 typed client) and
* 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. */
@@ -205,26 +205,26 @@ export default function SlideCanvas({
}: 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 ??留ㅼ묶 媛€??
// 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, 1280720 湲곗?). slide-base (title /
// divider / footer) ??洹??몃?. pendingLayout 紐⑤뱶 ?????곸뿭 ?덉뿉留?鍮?layout
// overlay 瑜?源붿븘??slide-base ??洹몃?濡??좎?.
// 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 ??媛뺤“ ?쒖떆.
// 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 1280720, range 0??00). key = data-image-id
// (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
@@ -241,9 +241,9 @@ export default function SlideCanvas({
// 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 怨?諛고???(異⑸룎 諛⑹?).
// 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
@@ -255,14 +255,14 @@ export default function SlideCanvas({
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
const iframeRef = useRef<HTMLIFrameElement>(null);
// ?몄쭛 紐⑤뱶 toggle ??iframe contentDocument ??湲€踰??⑦꽩 ?곸슜 / ?댁젣.
// 편집 모드 toggle iframe contentDocument 에 글벗 패턴 적용 / 해제.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
// ?몄쭛 紐⑤뱶 outline CSS ????踰덈쭔 二쇱엯 (id 濡?以묐났 諛⑹?)
// 편집 모드 outline CSS — 한 번만 주입 (id 로 중복 방지)
let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null;
if (!editStyle) {
editStyle = doc.createElement("style");
@@ -298,7 +298,7 @@ export default function SlideCanvas({
(el as HTMLElement).setAttribute("contenteditable", "true");
}
});
// ?몄쭛 諛쒖깮 ??hasPendingChanges ?몃━嫄?
// 편집 발생 시 hasPendingChanges 트리거.
inputHandler = () => {
onContentEdit?.();
};
@@ -363,7 +363,7 @@ export default function SlideCanvas({
};
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
// pendingLayout 吏꾩엯 ???몄쭛 紐⑤뱶 ?먮룞 OFF (異⑸룎 諛⑹?).
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => {
if (isPendingLayout && isEditMode) setEditMode("off");
}, [isPendingLayout, isEditMode]);
@@ -419,13 +419,13 @@ export default function SlideCanvas({
setEditMode("off");
}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);
// 16:9 鍮꾩쑉 ?좎??섎ʼn 而⑦뀒?대꼫???듭㎏濡?fit (?ㅽ겕濡?X).
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const updateScale = () => {
const padding = 32; // 而⑦뀒?대꼫 ?묒そ ?щ갚 (1rem * 2)
const padding = 32; // 컨테이너 양쪽 여백 (1rem * 2)
const cw = el.clientWidth - padding;
const ch = el.clientHeight - padding;
if (cw <= 0 || ch <= 0) return;
@@ -444,11 +444,11 @@ export default function SlideCanvas({
};
}, []);
// Empty state ??MDX ?낅줈????
// Empty state MDX 업로드 전.
const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
const hasNoCurrentRender =
!finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout;
// ?щ씪?대뱶 諛뺤뒪 ?쒖떆 議곌굔 ??final.html ?덇굅??pendingLayout 紐⑤뱶.
// 슬라이드 박스 표시 조건 — 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.
@@ -456,14 +456,14 @@ export default function SlideCanvas({
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 留?異붽?.
// 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. ?덉쓽 ?щ씪?대뱶??1280720 ?쇰줈
// top-left origin scale ??wrapper ?덉뿉 ?뺥솗??留욎땄.
// wrapper scaled 크기를 가지므로 layout fit. 안의 슬라이드는 1280×720 으로
// top-left origin scale wrapper 안에 정확히 맞춤.
const W_SCALED = SLIDE_W * scale;
const H_SCALED = SLIDE_H * scale;
@@ -506,8 +506,8 @@ export default function SlideCanvas({
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 ?쇰줈 ?ш린源뚯? ????
// wrapper 자체 클릭 (zone overlay 가 아닌 영역) = slide 전체 = Layout 탭.
// zone overlay stopPropagation 으로 여기까지 안 옴.
if (e.target === e.currentTarget && onSlideClick) onSlideClick();
}}
title={
@@ -516,7 +516,7 @@ export default function SlideCanvas({
: "Open layout panel"
}
>
{/* pendingLayout ???뚮쭔 ?몄텧?섎뒗 ?곗긽??痍⑥냼 踰꾪듉 (slide 諛뺤뒪 ?멸낸 ?좎?) */}
{/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */}
{isPendingLayout && onCancelPendingLayout && (
<button
onClick={(e) => {
@@ -526,7 +526,7 @@ export default function SlideCanvas({
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>
)}
@@ -593,7 +593,7 @@ export default function SlideCanvas({
key={embeddedSrc}
ref={iframeRef}
src={embeddedSrc}
title="Phase Z ?뚮뜑 寃곌낵"
title="Phase Z 렌더 결과"
className="w-full h-full border-0 block"
scrolling="no"
sandbox="allow-same-origin allow-scripts"
@@ -605,20 +605,20 @@ export default function SlideCanvas({
// 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.
// 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 痢≪젙 ??1280720 湲곗? ?뺢퇋??
// overlay 媛€ ??痢≪젙媛믪쑝濡?洹몃젮吏€誘€濡?backend ??layout (slide-base
// padding / dynamic ratios / grid) 洹몃?濡?諛섏쁺??
// ???? 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]"
);
@@ -639,8 +639,8 @@ export default function SlideCanvas({
});
setMeasuredZones(measured);
// .slide-body 痢≪젙 ??pendingLayout 紐⑤뱶 ?????곸뿭?먮쭔 鍮?
// overlay 瑜?源붿븘??slide-base (title / footer) ???좎?.
// .slide-body 측정 — pendingLayout 모드 시 이 영역에만 빈
// overlay 를 깔아서 slide-base (title / footer) 는 유지.
const bodyEl = doc.querySelector(".slide-body");
if (bodyEl) {
const r = (bodyEl as HTMLElement).getBoundingClientRect();
@@ -652,12 +652,12 @@ export default function SlideCanvas({
});
}
// ?€?€ IMP-51 (#79) u8 ??user-content image bbox 痢≪젙 ?€?€
// u4 stamper 媛€ 遺€李⑺븳 data-image-id 媛€ ?덈뒗 img 留??≪쓬
// (decorative / frame img ?쒖쇅). 痢≪젙 寃곌낵??1280720 湲곗?
// ?щ씪?대뱶-?덈? percent (0??00) ??image_overrides axis (u3
// ?€??+ u7 CSS `left/top/width/height: {value}%` 二쇱엯) ?€
// ?숈씪??醫뚰몴怨꾨씪??痢≪젙 / ?곴뎄 ?€??/ emit 媛€ 1:1 留ㅼ묶??
// ???? IMP-51 (#79) u8 ??user-content image bbox 측정 ????
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
// 슬라이드-절대 percent (0100) 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]'
);
@@ -679,17 +679,17 @@ export default function SlideCanvas({
});
setMeasuredImages(measuredImg);
} catch (err) {
console.warn("[SlideCanvas] iframe inject/measure ?ㅽ뙣:", 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 ?덈궡 ?쒖떆. */}
{/* ── 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"
@@ -710,29 +710,29 @@ export default function SlideCanvas({
{pendingLayoutId}
</span>
<span className="text-[10px] text-slate-400 block mt-1">
1. section ??zone drop &nbsp;&nbsp; 2. zone ? ??frame ? &nbsp;&nbsp; 3. ?ъ??
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 濡??쒖꽦. */}
{/* ???? 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 ???묒そ 蹂대?濡??쇨?).
// 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 臾댁떆 ??鍮??곹깭濡??쒖옉).
// 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] ??
@@ -743,10 +743,10 @@ export default function SlideCanvas({
.map((s) => `S${s.split("-").pop() ?? s}`)
.join("+")
: "(empty)";
// 醫뚰몴 寃곗젙 (schema ?듭씪 ???ъ슜??override ????긽 slide-body ?대? 0~1) :
// override ?덉쓬 (zone_geometries) ??slide-body 湲곗? ??1280720 ?덈? 蹂€??
// pending mode + override ?놁쓬 ??zone.position (slide-body 湲곗?) 蹂€??
// normal mode + override ?놁쓬 ??measuredZones (1280720 ?덈?) 吏곸젒.
// 좌표 결정 (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 }
@@ -759,7 +759,7 @@ export default function SlideCanvas({
};
let x: number, y: number, width: number, height: number;
if (overrideGeom && (measuredSlideBody || isPendingLayout)) {
// ?ъ슜??override ??slide-body 湲곗?
// 사용자 override slide-body 기준
x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w;
y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h;
width = overrideGeom.w * pendingSlideBody.w;
@@ -777,10 +777,10 @@ export default function SlideCanvas({
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 媛€ ?먯뿰.
// 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"
@@ -793,10 +793,10 @@ export default function SlideCanvas({
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).
// 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();
@@ -813,8 +813,8 @@ export default function SlideCanvas({
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.
// 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";
@@ -830,7 +830,7 @@ export default function SlideCanvas({
h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
}
if (affectsLeft) {
// 醫뚯륫 寃쎄퀎 ?대룞 ??w 以꾩씠怨?x ?섎┝ (?먮뒗 諛섎?)
// 좌측 경계 이동 — w 줄이고 x 늘림 (또는 반대)
const newW = Math.max(minSize, startGeom.w - dx);
x = Math.max(0, startGeom.x + (startGeom.w - newW));
w = newW;
@@ -859,10 +859,10 @@ export default function SlideCanvas({
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 蹂€寃?
// 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>
) => {
@@ -917,7 +917,7 @@ export default function SlideCanvas({
document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
if (!dragged) {
// ?⑥닚 click ?쇰줈 泥섎━ ??onZoneClick.
// 단순 click 으로 처리 — onZoneClick.
onZoneClick?.(zone.id);
}
};
@@ -925,9 +925,9 @@ export default function SlideCanvas({
document.addEventListener("mouseup", onUp);
};
// Step A : ?ъ슜?먭? ?ㅻⅨ frame ??怨⑤옄????洹?preview 瑜?zone ?덉뿉
// 諛섑닾紐낆쑝濡?誘몃━蹂닿린. final.html (iframe) ??default frame 怨??ㅻ? ?뚮쭔.
// override ?녾굅??default ?€ 媛숈쑝硫?誘몃━蹂닿린 X (iframe 怨?以묐났).
// 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
@@ -973,19 +973,19 @@ export default function SlideCanvas({
onDragEnter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
// 釉뚮씪?곗???custom dataTransfer type ??lowercase 媛뺤젣 ???묒そ ??check.
// 브라우저는 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 ?놁씠 臾댁“嫄??덉슜.
// drop target 이려면 onDragOver 에서 preventDefault 필수 — type check 없이 무조건 허용.
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}}
onDragLeave={(ev) => {
// ?먯떇?쇰줈 ?ㅼ뼱媛€硫댁꽌 諛쒖깮?섎뒗 leave 臾댁떆 ???ㅼ젣 leave 留?泥섎━.
// 자식으로 들어가면서 발생하는 leave 무시 — 실제 leave 만 처리.
if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
setDragOverZoneId((p) => (p === zone.id ? null : p));
}}
@@ -993,7 +993,7 @@ export default function SlideCanvas({
ev.preventDefault();
ev.stopPropagation();
setDragOverZoneId(null);
// setData ?덈뜕 紐⑤뱺 type ???쒕룄 ??case 蹂€??/ ?ㅻⅨ ??fallback.
// setData 했던 모든 type 을 시도 — case 변환 / 다른 키 fallback.
const sectionId =
ev.dataTransfer.getData("sectionId") ||
ev.dataTransfer.getData("section-id") ||
@@ -1024,8 +1024,8 @@ export default function SlideCanvas({
top: `${y * 100}%`,
width: `${width * 100}%`,
height: `${height * 100}%`,
// ?몄쭛 紐⑤뱶 : zone overlay ?먯껜???대┃ ?듦낵 (?띿뒪???몄쭛 ?곗꽑),
// resize handle 留?洹??덉뿉??pointer-events: auto.
// 편집 모드 : zone overlay 자체는 클릭 통과 (텍스트 편집 우선),
// resize handle 만 그 안에서 pointer-events: auto.
pointerEvents: isEditMode ? "none" : "auto",
}}
title={
@@ -1034,7 +1034,7 @@ export default function SlideCanvas({
: `${sectionLabel} (zone: ${zone.zone_id}) - open frame panel or drop a section`
}
>
{/* Step A : ?좏깮 frame preview 誘몃━蹂닿린 (諛섑닾紐? iframe 怨?以묐났 X) */}
{/* Step A : 선택 frame preview 미리보기 (반투명, iframe 과 중복 X) */}
{previewUrl && (
<>
<img
@@ -1042,7 +1042,7 @@ export default function SlideCanvas({
alt={`${zone.zone_id} preview`}
className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none"
/>
{/* "preview" 諛곗? ???곗긽??*/}
{/* "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>
@@ -1061,8 +1061,8 @@ export default function SlideCanvas({
</>
)}
{/* zone ?쇰꺼 ??醫뚯긽?? 二??쇰꺼 = section ids (S1, S1+S2),
遺€ ?쇰꺼 = backend zone position (top, bottom, primary). */}
{/* 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 ${
@@ -1080,7 +1080,7 @@ export default function SlideCanvas({
</span>
</div>
{/* drop hint ??drag over ??媛€?대뜲???덈궡 */}
{/* 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">
@@ -1089,13 +1089,13 @@ export default function SlideCanvas({
</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 ?숈떆. */}
{/* 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 */}
@@ -1104,7 +1104,7 @@ export default function SlideCanvas({
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 議곗젅"
title="상단 boundary 조절"
/>
{/* bottom edge */}
<div
@@ -1112,7 +1112,7 @@ export default function SlideCanvas({
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 議곗젅"
title="상단 boundary 조절"
/>
{/* left edge */}
<div
@@ -1120,7 +1120,7 @@ export default function SlideCanvas({
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 議곗젅"
title="좌측 boundary 조절"
/>
{/* right edge */}
<div
@@ -1128,39 +1128,39 @@ export default function SlideCanvas({
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 議곗젅"
title="좌측 boundary 조절"
/>
{/* corner NW (醫뚯긽?? */}
{/* 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="醫뚯긽???숈떆 議곗젅"
title="좌상단 동시 조절"
/>
{/* corner NE (?곗긽?? */}
{/* 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="?곗긽???숈떆 議곗젅"
title="좌상단 동시 조절"
/>
{/* corner SW (醫뚰븯?? */}
{/* 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="醫뚰븯???숈떆 議곗젅"
title="좌하단 동시 조절"
/>
{/* corner SE (?고븯?? */}
{/* 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="?고븯???숈떆 議곗젅"
title="좌하단 동시 조절"
/>
</>
)}
@@ -1177,8 +1177,8 @@ export default function SlideCanvas({
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 ??紐⑤뱶 紐⑤몢). */}
IMP-90 u12: image-zone-mode-only text / structure 모드는
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<>
<div
@@ -1257,7 +1257,7 @@ export default function SlideCanvas({
);
})}
{/* ?€?€ IMP-51 (#79) u8 ??user-content image edit overlay ?€?€
{/* ???? 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
@@ -1285,15 +1285,15 @@ export default function SlideCanvas({
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 諛섑솚). */}
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.
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
const geom = persisted ?? measured;
if (!geom) return null;
const isSelected = selectedImageId === imageId;
@@ -1309,8 +1309,8 @@ export default function SlideCanvas({
const startMouseY = ev.clientY;
const startGeom = { ...geom };
// 2026-05-22 demo hot-fix parity ??iframe ??留덉슦??媛€濡?
// 梨꾩꽌 mouseup leak ?쇱뼱??(?몄쭛 紐⑤뱶?먯꽌 pe=auto).
// 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";
@@ -1375,8 +1375,8 @@ export default function SlideCanvas({
}}
title={
isSelected
? "?대?吏€ ?대룞 ???쒕옒洹?/ 紐⑥꽌由??몃뱾 = ?ш린 (Shift = 鍮꾩쑉 ?댁젣)"
: "?대┃?섏뿬 ?좏깮"
? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (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">
@@ -1391,28 +1391,28 @@ export default function SlideCanvas({
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="?곷떒"
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="?섎떒"
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="醫뚯륫"
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="?곗륫"
title="좌측"
/>
{/* corners ??aspect locked by default, Shift unlocks */}
<div
@@ -1420,28 +1420,28 @@ export default function SlideCanvas({
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 = 鍮꾩쑉 ?댁젣)"
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 = 鍮꾩쑉 ?댁젣)"
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 = 鍮꾩쑉 ?댁젣)"
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 = 鍮꾩쑉 ?댁젣)"
title="좌하단 (Shift = 비율 해제)"
/>
</>
)}