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 ???뚯씠?꾨씪???ㅽ뻾 ??寃곌낵 ?쒖떆): * 역할 (Phase 1 MDX upload → 파이프라인 실행 → 결과 표시):
* - MDX ?낅줈????server 媛€ Phase Z ?ㅽ뻾 * - MDX 업로드 후 server Phase Z 실행
* - run_id ??final.html ??iframe ?쇰줈 ?쒖떆 * - run_id final.html iframe 으로 표시
* - ?쒕??덉씠??/ mock ?щ씪?대뱶 layout ?뚮뜑 X * - 시뮬레이션 / mock 슬라이드 layout 렌더 X
* *
* ?댁쟾 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES / * 이전 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
* Phase Z Engine header / Summary footer) 紐⑤몢 ?쒓굅 ??frontend 媛€ ?щ씪?대뱶瑜?*留뚮뱾吏€ ?딆쓬*, * Phase Z Engine header / Summary footer) 모두 제거 — frontend 가 슬라이드를 *만들지 않음*,
* backend 寃곌낵瑜?*?쎄린留???. * backend 결과를 *읽기만 함*.
* *
* Layout / zone / region / frame 蹂€寃?UI ??醫뚯슦 ?⑤꼸 (LayoutPanel / FramePanel) * Layout / zone / region / frame 변경 UI 는 좌우 패널 (LayoutPanel / FramePanel)
* ?먯꽌 ?섑뻾. 蹂?而댄룷?뚰듃??*寃곌낵 viewer*. * 에서 수행. 본 컴포넌트는 *결과 viewer*.
*/ */
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
@@ -39,19 +39,19 @@ interface SlideCanvasProps {
slidePlan: SlidePlan | null; slidePlan: SlidePlan | null;
normalizedContent: NormalizedContent | null; normalizedContent: NormalizedContent | null;
userSelection: UserSelection; userSelection: UserSelection;
/** Phase Z 媛€ 留뚮뱺 final.html URL (iframe ?쇰줈 ?쒖떆). */ /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
finalHtmlUrl?: string; finalHtmlUrl?: string;
/** ?뚯씠?꾨씪???ㅽ뻾 以??쒖떆 (loading state). */ /** 파이프라인 실행 중 표시 (loading state). */
isPipelineRunning?: boolean; isPipelineRunning?: boolean;
/** Phase 2 : pending layout 紐⑤뱶 ??final.html iframe ?④린怨?鍮?layout zone 留??쒖떆. */ /** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
isPendingLayout?: boolean; isPendingLayout?: boolean;
pendingLayoutId?: string | null; pendingLayoutId?: string | null;
onCancelPendingLayout?: () => void; onCancelPendingLayout?: () => void;
/** ?몄쭛 紐⑤뱶 ?띿뒪??蹂€寃?諛쒖깮 ????Home.tsx 媛€ hasPendingChanges ?몃━嫄? */ /** 편집 모드 텍스트 변경 발생 시 — Home.tsx hasPendingChanges 트리거. */
onContentEdit?: () => void; onContentEdit?: () => void;
/** Zone overlay ?대┃ ???곗륫 Frame ??쑝濡??꾪솚 + 洹?zone ??frame ?꾨낫 ?쒖떆. */ /** Zone overlay 클릭 — 우측 Frame 탭으로 전환 + zone frame 후보 표시. */
onZoneClick?: (zoneId: string) => void; onZoneClick?: (zoneId: string) => void;
/** ?щ씪?대뱶 諛뺤뒪 (zone overlay ??鍮??곸뿭) ?대┃ ???곗륫 Layout ??쑝濡??꾪솚. */ /** 슬라이드 박스 (zone overlay 외 빈 영역) 클릭 — 우측 Layout 탭으로 전환. */
onSlideClick?: () => void; onSlideClick?: () => void;
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onFrameSelect?: (frameId: string) => void; onFrameSelect?: (frameId: string) => void;
@@ -61,7 +61,7 @@ interface SlideCanvasProps {
geometries: Record<string, { x: number; y: number; w: number; h: number }> geometries: Record<string, { x: number; y: number; w: number; h: number }>
) => void; ) => void;
/** IMP-51 (#79) u8 ??persisted slide-absolute image geometries /** 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 * the u3 typed-client `ImageOverride` contract and the u7 stamper that
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional; * emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
* u11 wires this from `userSelection.overrides.image_overrides`. When * u11 wires this from `userSelection.overrides.image_overrides`. When
@@ -70,7 +70,7 @@ interface SlideCanvasProps {
imageOverrides?: ImageOverridesOverride; imageOverrides?: ImageOverridesOverride;
/** IMP-51 (#79) u8 ??emitted when the user drags or resizes a stamped /** IMP-51 (#79) u8 ??emitted when the user drags or resizes a stamped
* user-content image. Geometry is slide-absolute percent (0??00 of * 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 * the u7 CSS injection that writes the values directly into
* `left/top/width/height: {value}%`. u10 wires this to a persistence * `left/top/width/height: {value}%`. u10 wires this to a persistence
* handler that updates `image_overrides` on user_overrides.json. */ * handler that updates `image_overrides` on user_overrides.json. */
@@ -205,26 +205,26 @@ export default function SlideCanvas({
}: SlideCanvasProps) { }: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
// iframe ??final.html ???ㅼ젣 .zone DOM boundingClientRect 痢≪젙媛?(?뺢퇋??0~1). // iframe final.html 의 실제 .zone DOM boundingClientRect 측정값 (정규화 0~1).
// key = data-zone-position (e.g., "top" / "bottom" / "primary"). ?닿쾶 SlidePlan // key = data-zone-position (e.g., "top" / "bottom" / "primary"). 이게 SlidePlan
// ??zone.zone_id 1:1 ?쇱튂?섎?濡?overlay rendering ??留ㅼ묶 媛€?? // zone.zone_id 1:1 일치하므로 overlay rendering 시 매칭 가능.
const [measuredZones, setMeasuredZones] = useState< const [measuredZones, setMeasuredZones] = useState<
Record<string, { x: number; y: number; w: number; h: number }> Record<string, { x: number; y: number; w: number; h: number }>
>({}); >({});
// iframe ??.slide-body ??bbox (?뺢퇋??0~1, 1280720 湲곗?). slide-base (title / // iframe .slide-body bbox (정규화 0~1, 1280×720 기준). slide-base (title /
// divider / footer) ??洹??몃?. pendingLayout 紐⑤뱶 ?????곸뿭 ?덉뿉留?鍮?layout // divider / footer) 는 그 외부. pendingLayout 모드 시 이 영역 안에만 빈 layout
// overlay 瑜?源붿븘??slide-base ??洹몃?濡??좎?. // overlay 를 깔아서 slide-base 는 그대로 유지.
const [measuredSlideBody, setMeasuredSlideBody] = useState<{ const [measuredSlideBody, setMeasuredSlideBody] = useState<{
x: number; x: number;
y: number; y: number;
w: number; w: number;
h: number; h: number;
} | null>(null); } | null>(null);
// Step B : section drag-drop drop target. ?ъ슜?먭? LeftMdxPanel ??section 移대뱶 // Step B : section drag-drop drop target. 사용자가 LeftMdxPanel section 카드
// 瑜?drag ?댁꽌 zone ??drop ??洹?zone ??section ?좊떦. dragOver ??媛뺤“ ?쒖떆. // drag 해서 zone drop 시 그 zone section 할당. dragOver 시 강조 표시.
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null); const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
// IMP-51 (#79) u8 ??measured user-content image bboxes inside iframe // 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 // stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
// onLoad measure block alongside measuredZones / measuredSlideBody. // onLoad measure block alongside measuredZones / measuredSlideBody.
// Units intentionally match the persisted `image_overrides` axis (u3 // 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 // Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
// leak across runs. // leak across runs.
const [selectedImageId, setSelectedImageId] = useState<string | null>(null); const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
// HTML ?몄쭛 紐⑤뱶 ??湲€踰??⑦꽩 (designMode + contentEditable + outline CSS) 李⑥슜. // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
// ?쒖꽦 ??iframe ???띿뒪??element 吏곸젒 ?대┃?섏뿬 ?섏젙 媛€?? backend 諛섏쁺?€ 蹂??묒뾽. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
// pendingLayout 怨?諛고???(異⑸룎 諛⑹?). // pendingLayout 과 배타적 (충돌 방지).
// IMP-90 (#90) u11 ??`editMode` enum replaces the prior boolean. The // IMP-90 (#90) u11 ??`editMode` enum replaces the prior boolean. The
// `isEditMode` shim is kept ONLY for the pendingLayout coupling + // `isEditMode` shim is kept ONLY for the pendingLayout coupling +
// zone-wrapper visual cues (border / hover / selected styling) that // zone-wrapper visual cues (border / hover / selected styling) that
@@ -255,14 +255,14 @@ export default function SlideCanvas({
const editGates = computeEditModeGates(editMode, !!isPendingLayout); const editGates = computeEditModeGates(editMode, !!isPendingLayout);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
// ?몄쭛 紐⑤뱶 toggle ??iframe contentDocument ??湲€踰??⑦꽩 ?곸슜 / ?댁젣. // 편집 모드 toggle iframe contentDocument 에 글벗 패턴 적용 / 해제.
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
if (!iframe) return; if (!iframe) return;
const doc = iframe.contentDocument; const doc = iframe.contentDocument;
if (!doc) return; if (!doc) return;
// ?몄쭛 紐⑤뱶 outline CSS ????踰덈쭔 二쇱엯 (id 濡?以묐났 諛⑹?) // 편집 모드 outline CSS — 한 번만 주입 (id 로 중복 방지)
let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null; let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null;
if (!editStyle) { if (!editStyle) {
editStyle = doc.createElement("style"); editStyle = doc.createElement("style");
@@ -298,7 +298,7 @@ export default function SlideCanvas({
(el as HTMLElement).setAttribute("contenteditable", "true"); (el as HTMLElement).setAttribute("contenteditable", "true");
} }
}); });
// ?몄쭛 諛쒖깮 ??hasPendingChanges ?몃━嫄? // 편집 발생 시 hasPendingChanges 트리거.
inputHandler = () => { inputHandler = () => {
onContentEdit?.(); onContentEdit?.();
}; };
@@ -363,7 +363,7 @@ export default function SlideCanvas({
}; };
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]); }, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
// pendingLayout 吏꾩엯 ???몄쭛 紐⑤뱶 ?먮룞 OFF (異⑸룎 諛⑹?). // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
useEffect(() => { useEffect(() => {
if (isPendingLayout && isEditMode) setEditMode("off"); if (isPendingLayout && isEditMode) setEditMode("off");
}, [isPendingLayout, isEditMode]); }, [isPendingLayout, isEditMode]);
@@ -419,13 +419,13 @@ export default function SlideCanvas({
setEditMode("off"); setEditMode("off");
}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]); }, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);
// 16:9 鍮꾩쑉 ?좎??섎ʼn 而⑦뀒?대꼫???듭㎏濡?fit (?ㅽ겕濡?X). // 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
const updateScale = () => { const updateScale = () => {
const padding = 32; // 而⑦뀒?대꼫 ?묒そ ?щ갚 (1rem * 2) const padding = 32; // 컨테이너 양쪽 여백 (1rem * 2)
const cw = el.clientWidth - padding; const cw = el.clientWidth - padding;
const ch = el.clientHeight - padding; const ch = el.clientHeight - padding;
if (cw <= 0 || ch <= 0) return; 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 isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
const hasNoCurrentRender = const hasNoCurrentRender =
!finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout; !finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout;
// ?щ씪?대뱶 諛뺤뒪 ?쒖떆 議곌굔 ??final.html ?덇굅??pendingLayout 紐⑤뱶. // 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
// Task 13b: pending layout is a draft canvas, not a final.html preview. // Task 13b: pending layout is a draft canvas, not a final.html preview.
// Hide the iframe entirely while pending so the prior committed render // Hide the iframe entirely while pending so the prior committed render
// cannot visually overlap the pending zones. // cannot visually overlap the pending zones.
@@ -456,14 +456,14 @@ export default function SlideCanvas({
const showSlideBox = (renderIframe || isPendingLayout) && !isPipelineRunning; const showSlideBox = (renderIframe || isPendingLayout) && !isPipelineRunning;
const pendingSlideBody = measuredSlideBody ?? { x: 0, y: 0, w: 1, h: 1 }; 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 // IMP-14 (Step 13 A-4) backend slide_base.html embedded vs standalone CSS
// contract 瑜?`?embedded=1` query 濡??뚯쑀. 湲곗〈 query string 蹂댁〈?섎㈃??flag 留?異붽?. // contract `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
const embeddedSrc = renderIframe && finalHtmlUrl const embeddedSrc = renderIframe && finalHtmlUrl
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1` ? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
: undefined; : undefined;
// wrapper ??scaled ?ш린瑜?媛€吏€誘€濡?layout ??fit. ?덉쓽 ?щ씪?대뱶??1280720 ?쇰줈 // wrapper scaled 크기를 가지므로 layout fit. 안의 슬라이드는 1280×720 으로
// top-left origin scale ??wrapper ?덉뿉 ?뺥솗??留욎땄. // top-left origin scale wrapper 안에 정확히 맞춤.
const W_SCALED = SLIDE_W * scale; const W_SCALED = SLIDE_W * scale;
const H_SCALED = SLIDE_H * 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)]" 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 }} style={{ width: W_SCALED, height: H_SCALED }}
onClick={(e) => { onClick={(e) => {
// wrapper ?먯껜 ?대┃ (zone overlay 媛€ ?꾨땶 ?곸뿭) = slide ?꾩껜 = Layout ?? // wrapper 자체 클릭 (zone overlay 가 아닌 영역) = slide 전체 = Layout 탭.
// zone overlay ??stopPropagation ?쇰줈 ?ш린源뚯? ???? // zone overlay stopPropagation 으로 여기까지 안 옴.
if (e.target === e.currentTarget && onSlideClick) onSlideClick(); if (e.target === e.currentTarget && onSlideClick) onSlideClick();
}} }}
title={ title={
@@ -516,7 +516,7 @@ export default function SlideCanvas({
: "Open layout panel" : "Open layout panel"
} }
> >
{/* pendingLayout ???뚮쭔 ?몄텧?섎뒗 ?곗긽??痍⑥냼 踰꾪듉 (slide 諛뺤뒪 ?멸낸 ?좎?) */} {/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */}
{isPendingLayout && onCancelPendingLayout && ( {isPendingLayout && onCancelPendingLayout && (
<button <button
onClick={(e) => { 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" 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" }} style={{ pointerEvents: "auto" }}
> >
</button> </button>
)} )}
@@ -593,7 +593,7 @@ export default function SlideCanvas({
key={embeddedSrc} key={embeddedSrc}
ref={iframeRef} ref={iframeRef}
src={embeddedSrc} src={embeddedSrc}
title="Phase Z ?뚮뜑 寃곌낵" title="Phase Z 렌더 결과"
className="w-full h-full border-0 block" className="w-full h-full border-0 block"
scrolling="no" scrolling="no"
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
@@ -605,20 +605,20 @@ export default function SlideCanvas({
// in-iframe gesture (u14 will overlay React-side controls). // in-iframe gesture (u14 will overlay React-side controls).
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }} style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
onLoad={(e) => { onLoad={(e) => {
// IMP-14 (Step 13 A-4) ??embedded vs standalone CSS reset ?€ backend // IMP-14 (Step 13 A-4) ??embedded vs standalone CSS reset ?? backend
// slide_base.html 媛€ `?embedded=1` query 濡??뚯쑀. frontend 媛€ ???댁긽 // slide_base.html `?embedded=1` query 로 소유. frontend 가 더 이상
// reset CSS 瑜?contentDocument ??inject ?섏? ?딆쓬. embedded query 媛€ // reset CSS contentDocument inject 하지 않음. embedded query
// backend auto-mode detection script 瑜?trigger ?댁꽌 html.embedded // backend auto-mode detection script trigger 해서 html.embedded
// class 瑜?遺숈씠怨?standalone-only body 洹쒖튃??reset. // class 를 붙이고 standalone-only body 규칙을 reset.
try { try {
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument; const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return; if (!doc) return;
// ?€?€ Zone DOM 痢≪젙 ?€?€ // ???? Zone DOM 측정 ????
// backend final.html ??.zone[data-zone-position="..."] ?붿냼瑜? // backend final.html .zone[data-zone-position="..."] 요소를
// 李얠븘??boundingClientRect 痢≪젙 ??1280720 湲곗? ?뺢퇋?? // 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
// overlay 媛€ ??痢≪젙媛믪쑝濡?洹몃젮吏€誘€濡?backend ??layout (slide-base // overlay 가 이 측정값으로 그려지므로 backend layout (slide-base
// padding / dynamic ratios / grid) 洹몃?濡?諛섏쁺?? // padding / dynamic ratios / grid) 그대로 반영됨.
const zoneEls = doc.querySelectorAll( const zoneEls = doc.querySelectorAll(
".zone[data-zone-position]" ".zone[data-zone-position]"
); );
@@ -639,8 +639,8 @@ export default function SlideCanvas({
}); });
setMeasuredZones(measured); setMeasuredZones(measured);
// .slide-body 痢≪젙 ??pendingLayout 紐⑤뱶 ?????곸뿭?먮쭔 鍮? // .slide-body 측정 — pendingLayout 모드 시 이 영역에만 빈
// overlay 瑜?源붿븘??slide-base (title / footer) ???좎?. // overlay 를 깔아서 slide-base (title / footer) 는 유지.
const bodyEl = doc.querySelector(".slide-body"); const bodyEl = doc.querySelector(".slide-body");
if (bodyEl) { if (bodyEl) {
const r = (bodyEl as HTMLElement).getBoundingClientRect(); const r = (bodyEl as HTMLElement).getBoundingClientRect();
@@ -652,12 +652,12 @@ export default function SlideCanvas({
}); });
} }
// ?€?€ IMP-51 (#79) u8 ??user-content image bbox 痢≪젙 ?€?€ // ???? IMP-51 (#79) u8 ??user-content image bbox 측정 ????
// u4 stamper 媛€ 遺€李⑺븳 data-image-id 媛€ ?덈뒗 img 留??≪쓬 // u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
// (decorative / frame img ?쒖쇅). 痢≪젙 寃곌낵??1280720 湲곗? // (decorative / frame img 제외). 측정 결과는 1280×720 기준
// ?щ씪?대뱶-?덈? percent (0??00) ??image_overrides axis (u3 // 슬라이드-절대 percent (0100) image_overrides axis (u3
// ?€??+ u7 CSS `left/top/width/height: {value}%` 二쇱엯) ?€ // ????+ u7 CSS `left/top/width/height: {value}%` 주입) ??
// ?숈씪??醫뚰몴怨꾨씪??痢≪젙 / ?곴뎄 ?€??/ emit 媛€ 1:1 留ㅼ묶?? // 동일한 좌표계라서 측정 / 영구 저장 / emit 1:1 매칭됨.
const imageEls = doc.querySelectorAll<HTMLImageElement>( const imageEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]' '.slide img[data-image-role="user-content"][data-image-id]'
); );
@@ -679,17 +679,17 @@ export default function SlideCanvas({
}); });
setMeasuredImages(measuredImg); setMeasuredImages(measuredImg);
} catch (err) { } catch (err) {
console.warn("[SlideCanvas] iframe inject/measure ?ㅽ뙣:", err); console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
} }
}} }}
/> />
)} )}
</div> </div>
{/* ?€?€ pendingLayout ??slide-body ?곸뿭 overlay ?€?€ {/* ── pendingLayout slide-body 영역 overlay ──
slide-base (title / divider / footer) ??final.html 洹몃?濡??좎?. slide-base (title / divider / footer) final.html 그대로 유지.
slide-body ?곸뿭?먮쭔 ??dashed overlay 源붿븘??湲곗〈 .zone ??媛€由ш퀬 slide-body 영역에만 흰 dashed overlay 깔아서 기존 .zone 들 가리고
??layout zone ?덈궡 ?쒖떆. */} layout zone 안내 표시. */}
{isPendingLayout && ( {isPendingLayout && (
<div <div
className="absolute bg-white border-2 border-dashed border-amber-400 z-10" className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
@@ -710,29 +710,29 @@ export default function SlideCanvas({
{pendingLayoutId} {pendingLayoutId}
</span> </span>
<span className="text-[10px] text-slate-400 block mt-1"> <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> </span>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* ?€?€ Zone overlay layer ?€?€ {/* ???? Zone overlay layer ????
normal mode : measuredZones (= iframe ??.zone DOM bbox) ?곗꽑. normal mode : measuredZones (= iframe .zone DOM bbox) 우선.
pendingLayout : zone.position (computeZonePositions 寃곌낵 = slide-body pendingLayout : zone.position (computeZonePositions 결과 = slide-body
?대? 0~1) ??measuredSlideBody ???덈? ?뺢퇋?붾줈 蹂€?? 내부 0~1) measuredSlideBody 안 절대 정규화로 변환.
isEditMode : zone overlay 諛섑닾紐?+ pointer-events: none ???띿뒪???몄쭛 isEditMode : zone overlay 반투명 + pointer-events: none → 텍스트 편집
諛⑺빐 X. resize handle 留?pointer-events: auto 濡??쒖꽦. */} 방해 X. resize handle pointer-events: auto 로 활성. */}
{slidePlan?.zones.map((zone) => { {slidePlan?.zones.map((zone) => {
// selectedZoneId ??zone.id ?먮뒗 zone.zone_id 以??대뒓 履쎌씠??留ㅼ묶 (utils // selectedZoneId zone.id 또는 zone.zone_id 중 어느 쪽이든 매칭 (utils
// ??getSelectedZone ???묒そ 蹂대?濡??쇨?). // getSelectedZone 도 양쪽 보므로 일관).
const isSelected = const isSelected =
userSelection.selectedZoneId === zone.id || userSelection.selectedZoneId === zone.id ||
userSelection.selectedZoneId === zone.zone_id; userSelection.selectedZoneId === zone.zone_id;
const isDragOver = dragOverZoneId === zone.id; const isDragOver = dragOverZoneId === zone.id;
// Step B : zone overlay ?쇰꺼 = section_ids 湲곕컲 (S1, S1+S2 ??. // Step B : zone overlay 라벨 = section_ids 기반 (S1, S1+S2 등).
// backend zone position (top/bottom/primary) ?€ 遺€ ?쇰꺼 / tooltip ?쇰줈. // backend zone position (top/bottom/primary) 은 부 라벨 / tooltip 으로.
// pendingLayout 紐⑤뱶?먯꽌??override 留??ъ슜 (default 臾댁떆 ??鍮??곹깭濡??쒖옉). // pendingLayout 모드에서는 override 만 사용 (default 무시 — 빈 상태로 시작).
const sectionIds = isPendingLayout const sectionIds = isPendingLayout
? userSelection.overrides.zone_sections?.[zone.zone_id] ?? [] ? userSelection.overrides.zone_sections?.[zone.zone_id] ?? []
: 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}`) .map((s) => `S${s.split("-").pop() ?? s}`)
.join("+") .join("+")
: "(empty)"; : "(empty)";
// 醫뚰몴 寃곗젙 (schema ?듭씪 ???ъ슜??override ????긽 slide-body ?대? 0~1) : // 좌표 결정 (schema 통일 — 사용자 override 는 항상 slide-body 내부 0~1) :
// override ?덉쓬 (zone_geometries) ??slide-body 湲곗? ??1280720 ?덈? 蹂€?? // override 있음 (zone_geometries) slide-body 기준 → 1280×720 절대 변환.
// pending mode + override ?놁쓬 ??zone.position (slide-body 湲곗?) 蹂€?? // pending mode + override 없음 → zone.position (slide-body 기준) 변환.
// normal mode + override ?놁쓬 ??measuredZones (1280720 ?덈?) 吏곸젒. // normal mode + override 없음 → measuredZones (1280×720 절대) 직접.
const overrideGeom = const overrideGeom =
userSelection.overrides.zone_geometries?.[zone.zone_id] as userSelection.overrides.zone_geometries?.[zone.zone_id] as
| { x: number; y: number; w: number; h: number } | { 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; let x: number, y: number, width: number, height: number;
if (overrideGeom && (measuredSlideBody || isPendingLayout)) { if (overrideGeom && (measuredSlideBody || isPendingLayout)) {
// ?ъ슜??override ??slide-body 湲곗? // 사용자 override slide-body 기준
x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w; x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w;
y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h; y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h;
width = overrideGeom.w * pendingSlideBody.w; width = overrideGeom.w * pendingSlideBody.w;
@@ -777,10 +777,10 @@ export default function SlideCanvas({
height = measured?.h ?? zone.position.height; height = measured?.h ?? zone.position.height;
} }
// Step C : pending mode ??zone overlay resize handles ??independent 紐⑤뜽. // Step C : pending mode ??zone overlay resize handles ??independent 모델.
// 媛?zone ???낅┰ 諛뺤뒪 ???몄젒 zone ?곹뼢 X. ?먯쑀 諛곗튂 (gap / 寃뱀묠 ?ъ슜??梨낆엫). // zone 이 독립 박스 — 인접 zone 영향 X. 자유 배치 (gap / 겹침 사용자 책임).
// boundary drag 紐⑤뜽 (?몄젒 ?먮룞 議곗젙) ?€ grid 媛숈? 臾띠쓬 layout ???곹빀?쒕뜲, // boundary drag 모델 (인접 자동 조정) 은 grid 같은 묶음 layout 에 적합한데,
// ?ъ슜???섎룄 = zone position drag 源뚯? ?먯쑀濡?쾶 ??independent 媛€ ?먯뿰. // 사용자 의도 = zone position drag 까지 자유롭게 → independent 가 자연.
type ResizeDir = type ResizeDir =
| "top" | "top"
| "bottom" | "bottom"
@@ -793,10 +793,10 @@ export default function SlideCanvas({
const makeResizeHandler = ( const makeResizeHandler = (
direction: ResizeDir direction: ResizeDir
) => (ev: React.MouseEvent<HTMLDivElement>) => { ) => (ev: React.MouseEvent<HTMLDivElement>) => {
// resize ??pendingLayout OR image-zone ?몄쭛 紐⑤뱶 ?쒖꽦. 2026-05-22 // resize pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
// demo hot-fix ??frame partial ??@container aspect-ratio ?뚯쟾?? // demo hot-fix frame partial @container aspect-ratio 회전이
// ?ㅼ뼱媛€??fixed px ?쒖빟 ?щ씪吏? IMP-90 u12: text/structure 紐⑤뱶 // 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
// ?먯꽌??zone resize 鍮꾪솢??(mutually exclusive per editGates). // 에서는 zone resize 비활성 (mutually exclusive per editGates).
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return; if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -813,8 +813,8 @@ export default function SlideCanvas({
const affectsTop = direction === "top" || direction === "nw" || direction === "ne"; const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se"; const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
// 2026-05-22 demo hot-fix ??iframe ??留덉슦??媛€濡쒖콈??mouseup leak ?쇱뼱?? // 2026-05-22 demo hot-fix iframe 이 마우스 가로채서 mouseup leak 일어남
// (?몄쭛 紐⑤뱶?먯꽌 iframe pointerEvents=auto). drag ?숈븞 iframe 媛뺤젣 none. // (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
const iframeEl = iframeRef.current; const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : ""; const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none"; 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)); h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
} }
if (affectsLeft) { if (affectsLeft) {
// 醫뚯륫 寃쎄퀎 ?대룞 ??w 以꾩씠怨?x ?섎┝ (?먮뒗 諛섎?) // 좌측 경계 이동 — w 줄이고 x 늘림 (또는 반대)
const newW = Math.max(minSize, startGeom.w - dx); const newW = Math.max(minSize, startGeom.w - dx);
x = Math.max(0, startGeom.x + (startGeom.w - newW)); x = Math.max(0, startGeom.x + (startGeom.w - newW));
w = newW; w = newW;
@@ -859,10 +859,10 @@ export default function SlideCanvas({
const handleResizeSW = makeResizeHandler("sw"); const handleResizeSW = makeResizeHandler("sw");
const handleResizeSE = makeResizeHandler("se"); const handleResizeSE = makeResizeHandler("se");
// zone position drag ??pending mode ?먯꽌 zone body (handle ???곸뿭) // zone position drag pending mode 에서 zone body (handle 외 영역)
// mousedown drag 濡?x/y ?대룞. drag vs click 援щ텇 : // mousedown drag x/y 이동. drag vs click 구분 :
// 5px 誘몃쭔 ?대룞 = click ??onZoneClick // 5px 미만 이동 = click onZoneClick
// 5px ?댁긽 = drag ??onZoneResize 濡?x/y 蹂€寃? // 5px 이상 = drag onZoneResize x/y 변경
const handleZoneMouseDown = ( const handleZoneMouseDown = (
ev: React.MouseEvent<HTMLDivElement> ev: React.MouseEvent<HTMLDivElement>
) => { ) => {
@@ -917,7 +917,7 @@ export default function SlideCanvas({
document.removeEventListener("mouseup", onUp); document.removeEventListener("mouseup", onUp);
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE; if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
if (!dragged) { if (!dragged) {
// ?⑥닚 click ?쇰줈 泥섎━ ??onZoneClick. // 단순 click 으로 처리 — onZoneClick.
onZoneClick?.(zone.id); onZoneClick?.(zone.id);
} }
}; };
@@ -925,9 +925,9 @@ export default function SlideCanvas({
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
}; };
// Step A : ?ъ슜?먭? ?ㅻⅨ frame ??怨⑤옄????洹?preview 瑜?zone ?덉뿉 // Step A : 사용자가 다른 frame 을 골랐을 때 그 preview zone 안에
// 諛섑닾紐낆쑝濡?誘몃━蹂닿린. final.html (iframe) ??default frame 怨??ㅻ? ?뚮쭔. // 반투명으로 미리보기. final.html (iframe) default frame 과 다를 때만.
// override ?녾굅??default ?€ 媛숈쑝硫?誘몃━蹂닿린 X (iframe 怨?以묐났). // override 없거나 default 와 같으면 미리보기 X (iframe 과 중복).
const region = zone.internal_regions[0]; const region = zone.internal_regions[0];
const defaultFrameId = region?.frame_match_strategy.frame_id; const defaultFrameId = region?.frame_match_strategy.frame_id;
const overrideFrameId = region?.id const overrideFrameId = region?.id
@@ -973,19 +973,19 @@ export default function SlideCanvas({
onDragEnter={(ev) => { onDragEnter={(ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
// 釉뚮씪?곗???custom dataTransfer type ??lowercase 媛뺤젣 ???묒そ ??check. // 브라우저는 custom dataTransfer type lowercase 강제 — 양쪽 다 check.
const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase()); const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase());
if (types.includes("sectionid") || types.includes("section-id")) { if (types.includes("sectionid") || types.includes("section-id")) {
setDragOverZoneId(zone.id); setDragOverZoneId(zone.id);
} }
}} }}
onDragOver={(ev) => { onDragOver={(ev) => {
// drop target ?대젮硫?onDragOver ?먯꽌 preventDefault ?꾩닔 ??type check ?놁씠 臾댁“嫄??덉슜. // drop target 이려면 onDragOver 에서 preventDefault 필수 — type check 없이 무조건 허용.
ev.preventDefault(); ev.preventDefault();
ev.dataTransfer.dropEffect = "move"; ev.dataTransfer.dropEffect = "move";
}} }}
onDragLeave={(ev) => { onDragLeave={(ev) => {
// ?먯떇?쇰줈 ?ㅼ뼱媛€硫댁꽌 諛쒖깮?섎뒗 leave 臾댁떆 ???ㅼ젣 leave 留?泥섎━. // 자식으로 들어가면서 발생하는 leave 무시 — 실제 leave 만 처리.
if (ev.currentTarget.contains(ev.relatedTarget as Node)) return; if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
setDragOverZoneId((p) => (p === zone.id ? null : p)); setDragOverZoneId((p) => (p === zone.id ? null : p));
}} }}
@@ -993,7 +993,7 @@ export default function SlideCanvas({
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
setDragOverZoneId(null); setDragOverZoneId(null);
// setData ?덈뜕 紐⑤뱺 type ???쒕룄 ??case 蹂€??/ ?ㅻⅨ ??fallback. // setData 했던 모든 type 을 시도 — case 변환 / 다른 키 fallback.
const sectionId = const sectionId =
ev.dataTransfer.getData("sectionId") || ev.dataTransfer.getData("sectionId") ||
ev.dataTransfer.getData("section-id") || ev.dataTransfer.getData("section-id") ||
@@ -1024,8 +1024,8 @@ export default function SlideCanvas({
top: `${y * 100}%`, top: `${y * 100}%`,
width: `${width * 100}%`, width: `${width * 100}%`,
height: `${height * 100}%`, height: `${height * 100}%`,
// ?몄쭛 紐⑤뱶 : zone overlay ?먯껜???대┃ ?듦낵 (?띿뒪???몄쭛 ?곗꽑), // 편집 모드 : zone overlay 자체는 클릭 통과 (텍스트 편집 우선),
// resize handle 留?洹??덉뿉??pointer-events: auto. // resize handle 만 그 안에서 pointer-events: auto.
pointerEvents: isEditMode ? "none" : "auto", pointerEvents: isEditMode ? "none" : "auto",
}} }}
title={ title={
@@ -1034,7 +1034,7 @@ export default function SlideCanvas({
: `${sectionLabel} (zone: ${zone.zone_id}) - open frame panel or drop a section` : `${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 && ( {previewUrl && (
<> <>
<img <img
@@ -1042,7 +1042,7 @@ export default function SlideCanvas({
alt={`${zone.zone_id} preview`} alt={`${zone.zone_id} preview`}
className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none" 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"> <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 preview
</span> </span>
@@ -1061,8 +1061,8 @@ export default function SlideCanvas({
</> </>
)} )}
{/* zone ?쇰꺼 ??醫뚯긽?? 二??쇰꺼 = section ids (S1, S1+S2), {/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
遺€ ?쇰꺼 = backend zone position (top, bottom, primary). */} 부 라벨 = backend zone position (top, bottom, primary). */}
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none"> <div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
<span <span
className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${ className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${
@@ -1080,7 +1080,7 @@ export default function SlideCanvas({
</span> </span>
</div> </div>
{/* drop hint ??drag over ??媛€?대뜲???덈궡 */} {/* drop hint drag over 시 가운데에 안내 */}
{isDragOver && ( {isDragOver && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <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"> <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> </div>
)} )}
{/* Step C : zone resize handles ??8 諛⑺뼢. pendingLayout OR image-zone {/* Step C : zone resize handles ??8 방향. pendingLayout OR image-zone
?몄쭛 紐⑤뱶 ?쒖꽦. 2026-05-22 demo hot-fix ??frame partial ??@container 편집 모드 활성. 2026-05-22 demo hot-fix frame partial @container
aspect-ratio ?뚯쟾 ?ㅼ뼱媛???fixed px ?쒖빟 ?щ씪??image-zone 紐⑤뱶 resize aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
???섎? ?덉쓬. IMP-90 u12: text / structure 紐⑤뱶?먯꽌??zone resize 도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
affordance 誘몃끂異?(editGates.zoneGestures = image-zone only). affordance 미노출 (editGates.zoneGestures = image-zone only).
edge handle (top/bottom/left/right) : ??boundary ?대룞 edge handle (top/bottom/left/right) : boundary 이동
corner handle (nw/ne/sw/se) : ??boundary ?숈떆. */} corner handle (nw/ne/sw/se) : boundary 동시. */}
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && ( {(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
<> <>
{/* top edge */} {/* top edge */}
@@ -1104,7 +1104,7 @@ export default function SlideCanvas({
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곷떒 boundary 議곗젅" title="상단 boundary 조절"
/> />
{/* bottom edge */} {/* bottom edge */}
<div <div
@@ -1112,7 +1112,7 @@ export default function SlideCanvas({
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?섎떒 boundary 議곗젅" title="상단 boundary 조절"
/> />
{/* left edge */} {/* left edge */}
<div <div
@@ -1120,7 +1120,7 @@ export default function SlideCanvas({
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚯륫 boundary 議곗젅" title="좌측 boundary 조절"
/> />
{/* right edge */} {/* right edge */}
<div <div
@@ -1128,39 +1128,39 @@ export default function SlideCanvas({
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곗륫 boundary 議곗젅" title="좌측 boundary 조절"
/> />
{/* corner NW (醫뚯긽?? */} {/* corner NW (좌상단) */}
<div <div
onMouseDown={handleResizeNW} onMouseDown={handleResizeNW}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚯긽???숈떆 議곗젅" title="좌상단 동시 조절"
/> />
{/* corner NE (?곗긽?? */} {/* corner NE (우상단) */}
<div <div
onMouseDown={handleResizeNE} onMouseDown={handleResizeNE}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곗긽???숈떆 議곗젅" title="좌상단 동시 조절"
/> />
{/* corner SW (醫뚰븯?? */} {/* corner SW (좌하단) */}
<div <div
onMouseDown={handleResizeSW} onMouseDown={handleResizeSW}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚰븯???숈떆 議곗젅" title="좌하단 동시 조절"
/> />
{/* corner SE (?고븯?? */} {/* corner SE (우하단) */}
<div <div
onMouseDown={handleResizeSE} onMouseDown={handleResizeSE}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?고븯???숈떆 議곗젅" title="좌하단 동시 조절"
/> />
</> </>
)} )}
@@ -1177,8 +1177,8 @@ export default function SlideCanvas({
interior keeps text-edit reachability intact. interior keeps text-edit reachability intact.
pendingLayout mode already has wrapper pointerEvents:auto, pendingLayout mode already has wrapper pointerEvents:auto,
so these surfaces are only needed in edit mode. so these surfaces are only needed in edit mode.
IMP-90 u12: image-zone-mode-only ??text / structure 紐⑤뱶?? IMP-90 u12: image-zone-mode-only text / structure 모드는
zone drag ????(editGates.zoneGestures = false ??紐⑤뱶 紐⑤몢). */} zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
{editGates.zoneGestures && !isPendingLayout && onZoneResize && ( {editGates.zoneGestures && !isPendingLayout && onZoneResize && (
<> <>
<div <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 Activates only in edit mode when an image_id appears in either
`imageOverrides` (u11-fed persisted axis) or `measuredImages` `imageOverrides` (u11-fed persisted axis) or `measuredImages`
(iframe-measured baseline). pendingLayout suppresses the image (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 verified is exercised directly by vitest (mirror of how IMP-54
u3 split the zone math out of SlideCanvas). */} u3 split the zone math out of SlideCanvas). */}
{/* IMP-90 u12: image overlay is image-zone-mode-only. text / {/* IMP-90 u12: image overlay is image-zone-mode-only. text /
structure 紐⑤뱶?먯꽌??image drag/resize affordance 誘몃끂異? structure 모드에서는 image drag/resize affordance 미노출
(editGates.imageOverlay = false). pendingLayout ???숈씪?섍쾶 (editGates.imageOverlay = false). pendingLayout 도 동일하게
suppress (computeEditModeGates 媛€ 紐⑤몢 false 諛섑솚). */} suppress (computeEditModeGates 가 모두 false 반환). */}
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize && {!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map( Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
([imageId]) => { ([imageId]) => {
const persisted = imageOverrides?.[imageId]; const persisted = imageOverrides?.[imageId];
const measured = measuredImages[imageId]; const measured = measuredImages[imageId];
// override ?곗꽑; ?놁쑝硫?measured baseline. ?????놁쑝硫?skip. // override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
const geom = persisted ?? measured; const geom = persisted ?? measured;
if (!geom) return null; if (!geom) return null;
const isSelected = selectedImageId === imageId; const isSelected = selectedImageId === imageId;
@@ -1309,8 +1309,8 @@ export default function SlideCanvas({
const startMouseY = ev.clientY; const startMouseY = ev.clientY;
const startGeom = { ...geom }; const startGeom = { ...geom };
// 2026-05-22 demo hot-fix parity ??iframe ??留덉슦??媛€濡? // 2026-05-22 demo hot-fix parity iframe 이 마우스 가로
// 梨꾩꽌 mouseup leak ?쇱뼱??(?몄쭛 紐⑤뱶?먯꽌 pe=auto). // 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
const iframeEl = iframeRef.current; const iframeEl = iframeRef.current;
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : ""; const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
if (iframeEl) iframeEl.style.pointerEvents = "none"; if (iframeEl) iframeEl.style.pointerEvents = "none";
@@ -1375,8 +1375,8 @@ export default function SlideCanvas({
}} }}
title={ title={
isSelected 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"> <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()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곷떒" title="상단"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "bottom")} onMouseDown={(ev) => beginDrag(ev, "bottom")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?섎떒" title="상단"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "left")} onMouseDown={(ev) => beginDrag(ev, "left")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚯륫" title="좌측"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "right")} onMouseDown={(ev) => beginDrag(ev, "right")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곗륫" title="좌측"
/> />
{/* corners ??aspect locked by default, Shift unlocks */} {/* corners ??aspect locked by default, Shift unlocks */}
<div <div
@@ -1420,28 +1420,28 @@ export default function SlideCanvas({
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚯긽??(Shift = 鍮꾩쑉 ?댁젣)" title="좌상단 (Shift = 비율 해제)"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "ne")} onMouseDown={(ev) => beginDrag(ev, "ne")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?곗긽??(Shift = 鍮꾩쑉 ?댁젣)" title="좌상단 (Shift = 비율 해제)"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "sw")} onMouseDown={(ev) => beginDrag(ev, "sw")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="醫뚰븯??(Shift = 鍮꾩쑉 ?댁젣)" title="좌하단 (Shift = 비율 해제)"
/> />
<div <div
onMouseDown={(ev) => beginDrag(ev, "se")} onMouseDown={(ev) => beginDrag(ev, "se")}
onClick={(ev) => ev.stopPropagation()} 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" 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" }} style={{ pointerEvents: "auto" }}
title="?고븯??(Shift = 鍮꾩쑉 ?댁젣)" title="좌하단 (Shift = 비율 해제)"
/> />
</> </>
)} )}