- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1457 lines
71 KiB
TypeScript
1457 lines
71 KiB
TypeScript
/**
|
|
* SlideCanvas ??Phase Z ?ㅼ젣 寃곌낵 (final.html) iframe ?쒖떆.
|
|
*
|
|
* ??븷 (Phase 1 ??MDX upload ???뚯씠?꾨씪???ㅽ뻾 ??寃곌낵 ?쒖떆):
|
|
* - MDX ?낅줈????server 媛 Phase Z ?ㅽ뻾
|
|
* - run_id ??final.html ??iframe ?쇰줈 ?쒖떆
|
|
* - ?쒕??덉씠??/ mock ?щ씪?대뱶 layout ?뚮뜑 X
|
|
*
|
|
* ?댁쟾 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
|
|
* Phase Z Engine header / Summary footer) 紐⑤몢 ?쒓굅 ??frontend 媛 ?щ씪?대뱶瑜?*留뚮뱾吏 ?딆쓬*,
|
|
* backend 寃곌낵瑜?*?쎄린留???.
|
|
*
|
|
* Layout / zone / region / frame 蹂寃?UI ??醫뚯슦 ?⑤꼸 (LayoutPanel / FramePanel)
|
|
* ?먯꽌 ?섑뻾. 蹂?而댄룷?뚰듃??*寃곌낵 viewer*.
|
|
*/
|
|
|
|
import { useRef, useEffect, useState } from "react";
|
|
import { Loader2, Upload as UploadIcon } from "lucide-react";
|
|
import type {
|
|
SlidePlan,
|
|
UserSelection,
|
|
NormalizedContent,
|
|
} from "../types/designAgent";
|
|
import {
|
|
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
|
clampImagePercentGeometry,
|
|
clampZoneMove,
|
|
crossedDragThreshold,
|
|
type ImageDragDirection,
|
|
} from "./slideCanvasDragMath";
|
|
import type {
|
|
ImageOverridesOverride,
|
|
StructureOverridesOverride,
|
|
StructureOverridePerZone,
|
|
} from "../services/userOverridesApi";
|
|
import StructureEditOverlay from "./StructureEditOverlay";
|
|
|
|
interface SlideCanvasProps {
|
|
slidePlan: SlidePlan | null;
|
|
normalizedContent: NormalizedContent | null;
|
|
userSelection: UserSelection;
|
|
/** Phase Z 媛 留뚮뱺 final.html URL (iframe ?쇰줈 ?쒖떆). */
|
|
finalHtmlUrl?: string;
|
|
/** ?뚯씠?꾨씪???ㅽ뻾 以??쒖떆 (loading state). */
|
|
isPipelineRunning?: boolean;
|
|
/** Phase 2 : pending layout 紐⑤뱶 ??final.html iframe ?④린怨?鍮?layout zone 留??쒖떆. */
|
|
isPendingLayout?: boolean;
|
|
pendingLayoutId?: string | null;
|
|
onCancelPendingLayout?: () => void;
|
|
/** ?몄쭛 紐⑤뱶 ?띿뒪??蹂寃?諛쒖깮 ????Home.tsx 媛 hasPendingChanges ?몃━嫄? */
|
|
onContentEdit?: () => void;
|
|
/** Zone overlay ?대┃ ???곗륫 Frame ??쑝濡??꾪솚 + 洹?zone ??frame ?꾨낫 ?쒖떆. */
|
|
onZoneClick?: (zoneId: string) => void;
|
|
/** ?щ씪?대뱶 諛뺤뒪 (zone overlay ??鍮??곸뿭) ?대┃ ???곗륫 Layout ??쑝濡??꾪솚. */
|
|
onSlideClick?: () => void;
|
|
onRegionClick?: (regionId: string) => void;
|
|
onFrameSelect?: (frameId: string) => void;
|
|
onSectionDrop?: (sectionId: string, zoneId: string) => void;
|
|
onLayoutResize?: (groupId: string, sizes: number[]) => void;
|
|
onZoneResize?: (
|
|
geometries: Record<string, { x: number; y: number; w: number; h: number }>
|
|
) => void;
|
|
/** IMP-51 (#79) u8 ??persisted slide-absolute image geometries
|
|
* (image_id ??{x,y,w,h} as percent of 1280횞720, range 0??00). Mirrors
|
|
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
|
|
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
|
|
* u11 wires this from `userSelection.overrides.image_overrides`. When
|
|
* present, SlideCanvas displays the persisted geometry instead of the
|
|
* iframe-measured baseline. */
|
|
imageOverrides?: ImageOverridesOverride;
|
|
/** IMP-51 (#79) u8 ??emitted when the user drags or resizes a stamped
|
|
* user-content image. Geometry is slide-absolute percent (0??00 of
|
|
* 1280횞720), matching the persisted axis schema (u3 typed client) and
|
|
* the u7 CSS injection that writes the values directly into
|
|
* `left/top/width/height: {value}%`. u10 wires this to a persistence
|
|
* handler that updates `image_overrides` on user_overrides.json. */
|
|
onImageResize?: (
|
|
imageId: string,
|
|
geometry: { x: number; y: number; w: number; h: number }
|
|
) => void;
|
|
/** IMP-90 (#90) u13 ??focusout-emitted capture; u15 debounces + PUTs. */
|
|
onTextEdit?: (capture: TextEditCapture) => void;
|
|
/** IMP-90 (#90) u14 ??persisted structure overrides per zone
|
|
* (slot_order + hidden_slots). When `editMode === "structure"` the
|
|
* StructureEditOverlay reads from this to render the current state. */
|
|
structureOverrides?: StructureOverridesOverride;
|
|
/** IMP-90 (#90) u14 ??emitted whenever the user reorders or hides a
|
|
* slot in structure-mode. u15 will debounce + PUT to /api/user-
|
|
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
|
|
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
|
|
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
|
|
}
|
|
|
|
const SLIDE_W = 1280;
|
|
const SLIDE_H = 720;
|
|
|
|
// IMP-90 (#90) u11 ??discriminated edit mode. Replaces the prior single
|
|
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
|
|
// gesture gating (text contentEditable vs structure reorder vs image-zone
|
|
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
|
|
// existing behavior is preserved byte-identical. u12 will discriminate the
|
|
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
|
|
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
|
|
// because both are pointer-driven canvas gestures on slide geometry.
|
|
export type EditMode = "off" | "text" | "structure" | "image-zone";
|
|
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
|
|
/** Pure helper ??given the current edit mode and the user's requested mode,
|
|
* return the next mode. Clicking the active mode toggles back to 'off';
|
|
* clicking a different mode switches; explicit 'off' always exits. */
|
|
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
|
|
if (requested === "off") return "off";
|
|
return current === requested ? "off" : requested;
|
|
}
|
|
|
|
// IMP-90 (#90) u12 ??per-mode gesture gating. Pure helper deriving the
|
|
// boolean gates that drive SlideCanvas's useEffect branches (designMode
|
|
// + iframe-side image click listener) and JSX conditionals (iframe
|
|
// pointer-events, zone resize/drag affordances, image overlay). The
|
|
// mapping enforces the mutually-exclusive contract from the issue body:
|
|
// text -> contentEditable + iframe pointer-events:auto only.
|
|
// structure -> nothing here; u14 will plant the structure overlay.
|
|
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
|
|
// in-iframe user-content images can be click-selected.
|
|
// off -> every gate false (baseline).
|
|
// pendingLayout fully suppresses every gate ??mirrors the existing
|
|
// useEffect (line ~248) that forces editMode='off' on pendingLayout
|
|
// entry. The helper still defensively returns all-false so a stray
|
|
// pendingLayout=true with a non-'off' editMode never leaks gestures.
|
|
export interface EditModeGates {
|
|
textEditing: boolean;
|
|
imageSelection: boolean;
|
|
iframePointerAuto: boolean;
|
|
zoneGestures: boolean;
|
|
imageOverlay: boolean;
|
|
}
|
|
export function computeEditModeGates(
|
|
editMode: EditMode,
|
|
isPendingLayout: boolean
|
|
): EditModeGates {
|
|
if (isPendingLayout) {
|
|
return {
|
|
textEditing: false,
|
|
imageSelection: false,
|
|
iframePointerAuto: false,
|
|
zoneGestures: false,
|
|
imageOverlay: false,
|
|
};
|
|
}
|
|
return {
|
|
textEditing: editMode === "text",
|
|
imageSelection: editMode === "image-zone",
|
|
iframePointerAuto: editMode === "text" || editMode === "image-zone",
|
|
zoneGestures: editMode === "image-zone",
|
|
imageOverlay: editMode === "image-zone",
|
|
};
|
|
}
|
|
|
|
// IMP-90 (#90) u13 ??pure helper resolving a contentEditable focusout
|
|
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
|
|
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
|
|
// targets return null so capture silently skips. u15 will debounce + PUT.
|
|
export interface TextEditCaptureTarget {
|
|
closest(selector: string): TextEditCaptureTarget | null;
|
|
getAttribute(name: string): string | null;
|
|
textContent: string | null;
|
|
}
|
|
export interface TextEditCapture {
|
|
zoneId: string;
|
|
textPath: string;
|
|
value: string;
|
|
}
|
|
export function deriveTextEditCapture(
|
|
target: TextEditCaptureTarget | null
|
|
): TextEditCapture | null {
|
|
if (!target) return null;
|
|
const lineEl = target.closest("[data-text-path]");
|
|
if (!lineEl) return null;
|
|
const textPath = lineEl.getAttribute("data-text-path");
|
|
if (!textPath) return null;
|
|
const zoneEl = lineEl.closest(".zone[data-zone-position]");
|
|
if (!zoneEl) return null;
|
|
const zoneId = zoneEl.getAttribute("data-zone-position");
|
|
if (!zoneId) return null;
|
|
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
|
|
}
|
|
|
|
export default function SlideCanvas({
|
|
slidePlan,
|
|
userSelection,
|
|
finalHtmlUrl,
|
|
isPipelineRunning,
|
|
isPendingLayout,
|
|
pendingLayoutId,
|
|
onCancelPendingLayout,
|
|
onContentEdit,
|
|
onZoneClick,
|
|
onSlideClick,
|
|
onSectionDrop,
|
|
onZoneResize,
|
|
imageOverrides,
|
|
onImageResize,
|
|
onTextEdit,
|
|
structureOverrides,
|
|
onStructureEdit,
|
|
}: SlideCanvasProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [scale, setScale] = useState(1);
|
|
// iframe ??final.html ???ㅼ젣 .zone DOM boundingClientRect 痢≪젙媛?(?뺢퇋??0~1).
|
|
// key = data-zone-position (e.g., "top" / "bottom" / "primary"). ?닿쾶 SlidePlan
|
|
// ??zone.zone_id ? 1:1 ?쇱튂?섎?濡?overlay rendering ??留ㅼ묶 媛??
|
|
const [measuredZones, setMeasuredZones] = useState<
|
|
Record<string, { x: number; y: number; w: number; h: number }>
|
|
>({});
|
|
// iframe ??.slide-body ??bbox (?뺢퇋??0~1, 1280횞720 湲곗?). slide-base (title /
|
|
// divider / footer) ??洹??몃?. pendingLayout 紐⑤뱶 ?????곸뿭 ?덉뿉留?鍮?layout
|
|
// overlay 瑜?源붿븘??slide-base ??洹몃?濡??좎?.
|
|
const [measuredSlideBody, setMeasuredSlideBody] = useState<{
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
} | null>(null);
|
|
// Step B : section drag-drop drop target. ?ъ슜?먭? LeftMdxPanel ??section 移대뱶
|
|
// 瑜?drag ?댁꽌 zone ??drop ??洹?zone ??section ?좊떦. dragOver ??媛뺤“ ?쒖떆.
|
|
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
|
|
// IMP-51 (#79) u8 ??measured user-content image bboxes inside iframe
|
|
// (slide-absolute percent of 1280횞720, range 0??00). key = data-image-id
|
|
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
|
|
// onLoad measure block alongside measuredZones / measuredSlideBody.
|
|
// Units intentionally match the persisted `image_overrides` axis (u3
|
|
// typed client) and the u7 CSS injection so the overlay math has a
|
|
// single coord space across measured/persisted/emitted values. Used as
|
|
// the baseline geometry when no persisted override exists for that id;
|
|
// `imageOverrides` prop (u11-fed) wins when present.
|
|
const [measuredImages, setMeasuredImages] = useState<
|
|
Record<string, { x: number; y: number; w: number; h: number }>
|
|
>({});
|
|
// IMP-51 (#79) u8 ??currently selected user-content image id (= the one
|
|
// whose drag/resize handles are shown). Set by the click-listener
|
|
// installed inside the iframe contentDocument when edit mode is active.
|
|
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
|
|
// leak across runs.
|
|
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
|
|
// HTML ?몄쭛 紐⑤뱶 ??湲踰??⑦꽩 (designMode + contentEditable + outline CSS) 李⑥슜.
|
|
// ?쒖꽦 ??iframe ???띿뒪??element 吏곸젒 ?대┃?섏뿬 ?섏젙 媛?? backend 諛섏쁺? 蹂??묒뾽.
|
|
// pendingLayout 怨?諛고???(異⑸룎 諛⑹?).
|
|
// IMP-90 (#90) u11 ??`editMode` enum replaces the prior boolean. The
|
|
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
|
|
// zone-wrapper visual cues (border / hover / selected styling) that
|
|
// fire whenever any edit mode is active. u12 routes gesture-activating
|
|
// gates through `editGates` so text / structure / image-zone gestures
|
|
// are mutually exclusive.
|
|
const [editMode, setEditMode] = useState<EditMode>("off");
|
|
const isEditMode = editMode !== "off";
|
|
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
|
|
// ?몄쭛 紐⑤뱶 toggle ??iframe contentDocument ??湲踰??⑦꽩 ?곸슜 / ?댁젣.
|
|
useEffect(() => {
|
|
const iframe = iframeRef.current;
|
|
if (!iframe) return;
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) return;
|
|
|
|
// ?몄쭛 紐⑤뱶 outline CSS ????踰덈쭔 二쇱엯 (id 濡?以묐났 諛⑹?)
|
|
let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null;
|
|
if (!editStyle) {
|
|
editStyle = doc.createElement("style");
|
|
editStyle.id = "phase-z-edit-style";
|
|
editStyle.textContent = `
|
|
[contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); cursor: text; }
|
|
[contenteditable]:focus { outline: 2px solid #00C853 !important; outline-offset: 2px; }
|
|
`;
|
|
doc.head.appendChild(editStyle);
|
|
}
|
|
|
|
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
|
|
let inputHandler: ((e: Event) => void) | null = null;
|
|
// IMP-90 (#90) u13 ??focusout (= bubbling blur) emits one capture per
|
|
// finished line edit; u15 will debounce + PUT.
|
|
let textEditCaptureHandler: ((e: Event) => void) | null = null;
|
|
// IMP-51 (#79) u8 ??user-content image click listeners installed
|
|
// inside the iframe contentDocument. Tracked here so the cleanup
|
|
// callback can remove them when edit mode exits (or iframe reloads).
|
|
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
|
|
|
|
// IMP-90 (#90) u12 ??text-editing gate: only the 'text' editMode
|
|
// turns designMode on + makes the editable tags contentEditable.
|
|
// The else branch tears the prior state down so leaving text mode
|
|
// (to structure / image-zone / off) immediately disables in-place
|
|
// text editing ??required for mutual exclusivity vs the image-zone
|
|
// overlay's drag/resize gestures (a contentEditable cursor would
|
|
// otherwise be placed by every image click).
|
|
if (editGates.textEditing) {
|
|
doc.designMode = "on";
|
|
doc.querySelectorAll(".slide *").forEach((el) => {
|
|
if (editableTags.includes((el as HTMLElement).tagName)) {
|
|
(el as HTMLElement).setAttribute("contenteditable", "true");
|
|
}
|
|
});
|
|
// ?몄쭛 諛쒖깮 ??hasPendingChanges ?몃━嫄?
|
|
inputHandler = () => {
|
|
onContentEdit?.();
|
|
};
|
|
doc.addEventListener("input", inputHandler);
|
|
|
|
textEditCaptureHandler = (ev: Event) => {
|
|
const cap = deriveTextEditCapture(
|
|
ev.target as unknown as TextEditCaptureTarget | null
|
|
);
|
|
if (cap) onTextEdit?.(cap);
|
|
};
|
|
doc.addEventListener("focusout", textEditCaptureHandler);
|
|
} else {
|
|
doc.designMode = "off";
|
|
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
|
(el as HTMLElement).removeAttribute("contenteditable");
|
|
});
|
|
}
|
|
|
|
// IMP-90 (#90) u12 ??image-selection gate: only the 'image-zone'
|
|
// editMode wires the in-iframe user-content image click ??selection.
|
|
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
|
|
// (requires data-image-id which the stamper always emits). Decorative
|
|
// / frame imgs lacking the role attribute are NOT clickable. The
|
|
// else branch clears `selectedImageId` so the React-side overlay
|
|
// never lingers on a non-image-zone edit mode.
|
|
if (editGates.imageSelection) {
|
|
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
|
'.slide img[data-image-role="user-content"][data-image-id]'
|
|
);
|
|
imgEls.forEach((imgEl) => {
|
|
const imgId = imgEl.dataset.imageId;
|
|
if (!imgId) return;
|
|
const handler = (ev: Event) => {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
setSelectedImageId(imgId);
|
|
};
|
|
const prevCursor = imgEl.style.cursor;
|
|
const prevOutline = imgEl.style.outline;
|
|
imgEl.style.cursor = "pointer";
|
|
imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
|
|
imgEl.addEventListener("click", handler);
|
|
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
|
});
|
|
} else {
|
|
setSelectedImageId(null);
|
|
}
|
|
|
|
return () => {
|
|
if (inputHandler && doc) {
|
|
doc.removeEventListener("input", inputHandler);
|
|
}
|
|
if (textEditCaptureHandler && doc) {
|
|
doc.removeEventListener("focusout", textEditCaptureHandler);
|
|
}
|
|
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
|
el.removeEventListener("click", handler);
|
|
el.style.cursor = prevCursor;
|
|
el.style.outline = prevOutline;
|
|
});
|
|
};
|
|
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
|
|
|
|
// pendingLayout 吏꾩엯 ???몄쭛 紐⑤뱶 ?먮룞 OFF (異⑸룎 諛⑹?).
|
|
useEffect(() => {
|
|
if (isPendingLayout && isEditMode) setEditMode("off");
|
|
}, [isPendingLayout, isEditMode]);
|
|
|
|
// IMP-90 (#90) u14 ??discover slot keys per zone for the structure
|
|
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
|
|
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
|
|
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
|
|
// Discovery runs only when entering structure mode (and resets on exit
|
|
// or iframe reload) so off / text / image-zone modes never pay this
|
|
// traversal cost.
|
|
const [slotKeysByZone, setSlotKeysByZone] = useState<
|
|
Record<string, string[]>
|
|
>({});
|
|
useEffect(() => {
|
|
if (editMode !== "structure" || isPendingLayout) {
|
|
setSlotKeysByZone({});
|
|
return;
|
|
}
|
|
const doc = iframeRef.current?.contentDocument;
|
|
if (!doc) return;
|
|
const next: Record<string, string[]> = {};
|
|
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
|
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
|
if (!zoneId) return;
|
|
const seen = new Set<string>();
|
|
const keys: string[] = [];
|
|
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
|
|
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
|
|
if (!path) return;
|
|
const lastDot = path.lastIndexOf(".");
|
|
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
|
|
if (slotKey && !seen.has(slotKey)) {
|
|
seen.add(slotKey);
|
|
keys.push(slotKey);
|
|
}
|
|
});
|
|
next[zoneId] = keys;
|
|
});
|
|
setSlotKeysByZone(next);
|
|
}, [editMode, isPendingLayout, finalHtmlUrl]);
|
|
|
|
// #98 Task 4 ??reset iframe-derived UI state whenever a render is replaced,
|
|
// cleared, or hidden by a new pipeline run. This prevents stale zone
|
|
// measurements / edit overlays from lingering over the next final.html.
|
|
useEffect(() => {
|
|
setMeasuredZones({});
|
|
setMeasuredSlideBody(null);
|
|
setMeasuredImages({});
|
|
setSelectedImageId(null);
|
|
setDragOverZoneId(null);
|
|
setSlotKeysByZone({});
|
|
setEditMode("off");
|
|
}, [finalHtmlUrl, isPipelineRunning, isPendingLayout]);
|
|
|
|
// 16:9 鍮꾩쑉 ?좎??섎ʼn 而⑦뀒?대꼫???듭㎏濡?fit (?ㅽ겕濡?X).
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
const updateScale = () => {
|
|
const padding = 32; // 而⑦뀒?대꼫 ?묒そ ?щ갚 (1rem * 2)
|
|
const cw = el.clientWidth - padding;
|
|
const ch = el.clientHeight - padding;
|
|
if (cw <= 0 || ch <= 0) return;
|
|
const sw = cw / SLIDE_W;
|
|
const sh = ch / SLIDE_H;
|
|
setScale(Math.min(sw, sh));
|
|
};
|
|
|
|
updateScale();
|
|
const ro = new ResizeObserver(updateScale);
|
|
ro.observe(el);
|
|
window.addEventListener("resize", updateScale);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", updateScale);
|
|
};
|
|
}, []);
|
|
|
|
// Empty state ??MDX ?낅줈????
|
|
const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
|
|
const hasNoCurrentRender =
|
|
!finalHtmlUrl && !!slidePlan && !isPipelineRunning && !isPendingLayout;
|
|
// ?щ씪?대뱶 諛뺤뒪 ?쒖떆 議곌굔 ??final.html ?덇굅??pendingLayout 紐⑤뱶.
|
|
// Task 13b: pending layout is a draft canvas, not a final.html preview.
|
|
// Hide the iframe entirely while pending so the prior committed render
|
|
// cannot visually overlap the pending zones.
|
|
const renderIframe = !!finalHtmlUrl && !isPendingLayout;
|
|
const showSlideBox = (renderIframe || isPendingLayout) && !isPipelineRunning;
|
|
const pendingSlideBody = measuredSlideBody ?? { x: 0, y: 0, w: 1, h: 1 };
|
|
|
|
// IMP-14 (Step 13 A-4) ??backend slide_base.html 媛 embedded vs standalone CSS
|
|
// contract 瑜?`?embedded=1` query 濡??뚯쑀. 湲곗〈 query string 蹂댁〈?섎㈃??flag 留?異붽?.
|
|
const embeddedSrc = renderIframe && finalHtmlUrl
|
|
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
|
|
: undefined;
|
|
|
|
// wrapper ??scaled ?ш린瑜?媛吏誘濡?layout ??fit. ?덉쓽 ?щ씪?대뱶??1280횞720 ?쇰줈
|
|
// top-left origin scale ??wrapper ?덉뿉 ?뺥솗??留욎땄.
|
|
const W_SCALED = SLIDE_W * scale;
|
|
const H_SCALED = SLIDE_H * scale;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex-1 flex items-center justify-center bg-slate-100 p-4 relative overflow-hidden"
|
|
>
|
|
{isEmpty && (
|
|
<div className="text-center text-slate-400 select-none">
|
|
<UploadIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
|
<p className="text-sm font-medium">Upload an MDX file from the left panel.</p>
|
|
<p className="text-xs text-slate-300 mt-1">
|
|
After upload, run the slide generation action from the footer.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isPipelineRunning && (
|
|
<div className="text-center text-slate-500 select-none">
|
|
<Loader2 className="w-10 h-10 mx-auto mb-3 text-blue-500 animate-spin" />
|
|
<p className="text-sm font-bold">Phase Z pipeline is running...</p>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Parsing MDX, matching frames, choosing layout, and rendering.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{hasNoCurrentRender && (
|
|
<div className="text-center text-slate-500 select-none max-w-sm">
|
|
<p className="text-sm font-bold">No current render</p>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Run the pipeline to display a fresh final.html result.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{showSlideBox && (
|
|
<div
|
|
className="relative overflow-hidden cursor-pointer bg-white shadow-[0_40px_100px_-20px_rgba(0,0,0,0.2)]"
|
|
style={{ width: W_SCALED, height: H_SCALED }}
|
|
onClick={(e) => {
|
|
// wrapper ?먯껜 ?대┃ (zone overlay 媛 ?꾨땶 ?곸뿭) = slide ?꾩껜 = Layout ??
|
|
// zone overlay ??stopPropagation ?쇰줈 ?ш린源뚯? ????
|
|
if (e.target === e.currentTarget && onSlideClick) onSlideClick();
|
|
}}
|
|
title={
|
|
isPendingLayout
|
|
? `Pending layout: ${pendingLayoutId} - assign sections, choose frames, then regenerate`
|
|
: "Open layout panel"
|
|
}
|
|
>
|
|
{/* pendingLayout ???뚮쭔 ?몄텧?섎뒗 ?곗긽??痍⑥냼 踰꾪듉 (slide 諛뺤뒪 ?멸낸 ?좎?) */}
|
|
{isPendingLayout && onCancelPendingLayout && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCancelPendingLayout();
|
|
}}
|
|
className="absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2 py-1 rounded bg-slate-800 text-white hover:bg-slate-700 shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
>
|
|
痍⑥냼
|
|
</button>
|
|
)}
|
|
|
|
{/* IMP-90 (#90) u11 ??discriminated edit-mode toolbar.
|
|
Replaces the prior single ??toggle. Three modes (text /
|
|
structure / image-zone) are mutually exclusive; clicking the
|
|
active mode toggles back to 'off'. Gesture gating per mode is
|
|
u12 ??u11 only plants the state + UI surface, so all three
|
|
modes currently share the same `isEditMode` shim behavior. */}
|
|
{!isPendingLayout && finalHtmlUrl && (
|
|
<div
|
|
data-testid="edit-mode-toolbar"
|
|
className="absolute top-2 right-2 z-30 flex gap-1"
|
|
style={{ pointerEvents: "auto" }}
|
|
>
|
|
{EDIT_MODES.map((mode) => {
|
|
const active = editMode === mode;
|
|
const label =
|
|
mode === "text" ? "Text" : mode === "structure" ? "Structure" : "Image/Zone";
|
|
const title =
|
|
mode === "text"
|
|
? "Edit text in the rendered slide"
|
|
: mode === "structure"
|
|
? "Edit slot order and visibility"
|
|
: "Move or resize images and zones";
|
|
return (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
data-testid={`edit-mode-${mode}`}
|
|
aria-pressed={active}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditMode((prev) => nextEditMode(prev, mode));
|
|
}}
|
|
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
|
active
|
|
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
|
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
|
}`}
|
|
title={title}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
width: SLIDE_W,
|
|
height: SLIDE_H,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: "top left",
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{renderIframe && embeddedSrc && (
|
|
<iframe
|
|
key={embeddedSrc}
|
|
ref={iframeRef}
|
|
src={embeddedSrc}
|
|
title="Phase Z ?뚮뜑 寃곌낵"
|
|
className="w-full h-full border-0 block"
|
|
scrolling="no"
|
|
sandbox="allow-same-origin allow-scripts"
|
|
// IMP-90 (#90) u12 ??iframe pointer-events gate. 'text' needs
|
|
// pe:auto so the user can click into text fields; 'image-zone'
|
|
// needs pe:auto so user-content image clicks can reach the
|
|
// in-iframe click handler that drives `selectedImageId`.
|
|
// 'structure' and 'off' keep pe:none ??structure has no
|
|
// in-iframe gesture (u14 will overlay React-side controls).
|
|
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
|
onLoad={(e) => {
|
|
// IMP-14 (Step 13 A-4) ??embedded vs standalone CSS reset ? backend
|
|
// slide_base.html 媛 `?embedded=1` query 濡??뚯쑀. frontend 媛 ???댁긽
|
|
// reset CSS 瑜?contentDocument ??inject ?섏? ?딆쓬. embedded query 媛
|
|
// backend auto-mode detection script 瑜?trigger ?댁꽌 html.embedded
|
|
// class 瑜?遺숈씠怨?standalone-only body 洹쒖튃??reset.
|
|
try {
|
|
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
|
if (!doc) return;
|
|
|
|
// ?? Zone DOM 痢≪젙 ??
|
|
// backend final.html ??.zone[data-zone-position="..."] ?붿냼瑜?
|
|
// 李얠븘??boundingClientRect 痢≪젙 ??1280횞720 湲곗? ?뺢퇋??
|
|
// overlay 媛 ??痢≪젙媛믪쑝濡?洹몃젮吏誘濡?backend ??layout (slide-base
|
|
// padding / dynamic ratios / grid) 洹몃?濡?諛섏쁺??
|
|
const zoneEls = doc.querySelectorAll(
|
|
".zone[data-zone-position]"
|
|
);
|
|
const measured: Record<
|
|
string,
|
|
{ x: number; y: number; w: number; h: number }
|
|
> = {};
|
|
zoneEls.forEach((el) => {
|
|
const r = (el as HTMLElement).getBoundingClientRect();
|
|
const pos = (el as HTMLElement).dataset.zonePosition;
|
|
if (!pos) return;
|
|
measured[pos] = {
|
|
x: r.left / SLIDE_W,
|
|
y: r.top / SLIDE_H,
|
|
w: r.width / SLIDE_W,
|
|
h: r.height / SLIDE_H,
|
|
};
|
|
});
|
|
setMeasuredZones(measured);
|
|
|
|
// .slide-body 痢≪젙 ??pendingLayout 紐⑤뱶 ?????곸뿭?먮쭔 鍮?
|
|
// overlay 瑜?源붿븘??slide-base (title / footer) ???좎?.
|
|
const bodyEl = doc.querySelector(".slide-body");
|
|
if (bodyEl) {
|
|
const r = (bodyEl as HTMLElement).getBoundingClientRect();
|
|
setMeasuredSlideBody({
|
|
x: r.left / SLIDE_W,
|
|
y: r.top / SLIDE_H,
|
|
w: r.width / SLIDE_W,
|
|
h: r.height / SLIDE_H,
|
|
});
|
|
}
|
|
|
|
// ?? IMP-51 (#79) u8 ??user-content image bbox 痢≪젙 ??
|
|
// u4 stamper 媛 遺李⑺븳 data-image-id 媛 ?덈뒗 img 留??≪쓬
|
|
// (decorative / frame img ?쒖쇅). 痢≪젙 寃곌낵??1280횞720 湲곗?
|
|
// ?щ씪?대뱶-?덈? percent (0??00) ??image_overrides axis (u3
|
|
// ???+ u7 CSS `left/top/width/height: {value}%` 二쇱엯) ?
|
|
// ?숈씪??醫뚰몴怨꾨씪??痢≪젙 / ?곴뎄 ???/ emit 媛 1:1 留ㅼ묶??
|
|
const imageEls = doc.querySelectorAll<HTMLImageElement>(
|
|
'.slide img[data-image-role="user-content"][data-image-id]'
|
|
);
|
|
const measuredImg: Record<
|
|
string,
|
|
{ x: number; y: number; w: number; h: number }
|
|
> = {};
|
|
imageEls.forEach((imgEl) => {
|
|
const id = imgEl.dataset.imageId;
|
|
if (!id) return;
|
|
const r = imgEl.getBoundingClientRect();
|
|
if (r.width <= 0 || r.height <= 0) return;
|
|
measuredImg[id] = {
|
|
x: (r.left / SLIDE_W) * 100,
|
|
y: (r.top / SLIDE_H) * 100,
|
|
w: (r.width / SLIDE_W) * 100,
|
|
h: (r.height / SLIDE_H) * 100,
|
|
};
|
|
});
|
|
setMeasuredImages(measuredImg);
|
|
} catch (err) {
|
|
console.warn("[SlideCanvas] iframe inject/measure ?ㅽ뙣:", err);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ?? pendingLayout ??slide-body ?곸뿭 overlay ??
|
|
slide-base (title / divider / footer) ??final.html 洹몃?濡??좎?.
|
|
slide-body ?곸뿭?먮쭔 ??dashed overlay 源붿븘??湲곗〈 .zone ??媛由ш퀬
|
|
??layout zone ?덈궡 ?쒖떆. */}
|
|
{isPendingLayout && (
|
|
<div
|
|
className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
|
|
style={{
|
|
left: `${pendingSlideBody.x * 100}%`,
|
|
top: `${pendingSlideBody.y * 100}%`,
|
|
width: `${pendingSlideBody.w * 100}%`,
|
|
height: `${pendingSlideBody.h * 100}%`,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="text-center select-none">
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-amber-500 block mb-1">
|
|
Pending Body Layout
|
|
</span>
|
|
<span className="text-lg font-bold text-slate-500 block">
|
|
{pendingLayoutId}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 block mt-1">
|
|
1. section 移대뱶 ??zone drop 쨌 2. zone ?대┃ ??frame ?좏깮 쨌 3. ?ъ깮??
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ?? Zone overlay layer ??
|
|
normal mode : measuredZones (= iframe ??.zone DOM bbox) ?곗꽑.
|
|
pendingLayout : zone.position (computeZonePositions 寃곌낵 = slide-body
|
|
?대? 0~1) ??measuredSlideBody ???덈? ?뺢퇋?붾줈 蹂??
|
|
isEditMode : zone overlay 諛섑닾紐?+ pointer-events: none ???띿뒪???몄쭛
|
|
諛⑺빐 X. resize handle 留?pointer-events: auto 濡??쒖꽦. */}
|
|
{slidePlan?.zones.map((zone) => {
|
|
// selectedZoneId ??zone.id ?먮뒗 zone.zone_id 以??대뒓 履쎌씠??留ㅼ묶 (utils
|
|
// ??getSelectedZone ???묒そ 蹂대?濡??쇨?).
|
|
const isSelected =
|
|
userSelection.selectedZoneId === zone.id ||
|
|
userSelection.selectedZoneId === zone.zone_id;
|
|
const isDragOver = dragOverZoneId === zone.id;
|
|
// Step B : zone overlay ?쇰꺼 = section_ids 湲곕컲 (S1, S1+S2 ??.
|
|
// backend zone position (top/bottom/primary) ? 遺 ?쇰꺼 / tooltip ?쇰줈.
|
|
// pendingLayout 紐⑤뱶?먯꽌??override 留??ъ슜 (default 臾댁떆 ??鍮??곹깭濡??쒖옉).
|
|
const sectionIds = isPendingLayout
|
|
? userSelection.overrides.zone_sections?.[zone.zone_id] ?? []
|
|
: userSelection.overrides.zone_sections?.[zone.zone_id] ??
|
|
zone.section_ids;
|
|
const sectionLabel =
|
|
sectionIds.length > 0
|
|
? sectionIds
|
|
.map((s) => `S${s.split("-").pop() ?? s}`)
|
|
.join("+")
|
|
: "(empty)";
|
|
// 醫뚰몴 寃곗젙 (schema ?듭씪 ???ъ슜??override ????긽 slide-body ?대? 0~1) :
|
|
// override ?덉쓬 (zone_geometries) ??slide-body 湲곗? ??1280횞720 ?덈? 蹂??
|
|
// pending mode + override ?놁쓬 ??zone.position (slide-body 湲곗?) 蹂??
|
|
// normal mode + override ?놁쓬 ??measuredZones (1280횞720 ?덈?) 吏곸젒.
|
|
const overrideGeom =
|
|
userSelection.overrides.zone_geometries?.[zone.zone_id] as
|
|
| { x: number; y: number; w: number; h: number }
|
|
| undefined;
|
|
const localGeom = overrideGeom ?? {
|
|
x: zone.position.x,
|
|
y: zone.position.y,
|
|
w: zone.position.width,
|
|
h: zone.position.height,
|
|
};
|
|
let x: number, y: number, width: number, height: number;
|
|
if (overrideGeom && (measuredSlideBody || isPendingLayout)) {
|
|
// ?ъ슜??override ??slide-body 湲곗?
|
|
x = pendingSlideBody.x + overrideGeom.x * pendingSlideBody.w;
|
|
y = pendingSlideBody.y + overrideGeom.y * pendingSlideBody.h;
|
|
width = overrideGeom.w * pendingSlideBody.w;
|
|
height = overrideGeom.h * pendingSlideBody.h;
|
|
} else if (isPendingLayout) {
|
|
x = pendingSlideBody.x + zone.position.x * pendingSlideBody.w;
|
|
y = pendingSlideBody.y + zone.position.y * pendingSlideBody.h;
|
|
width = zone.position.width * pendingSlideBody.w;
|
|
height = zone.position.height * pendingSlideBody.h;
|
|
} else {
|
|
const measured = measuredZones[zone.zone_id];
|
|
x = measured?.x ?? zone.position.x;
|
|
y = measured?.y ?? zone.position.y;
|
|
width = measured?.w ?? zone.position.width;
|
|
height = measured?.h ?? zone.position.height;
|
|
}
|
|
|
|
// Step C : pending mode ??zone overlay resize handles ??independent 紐⑤뜽.
|
|
// 媛?zone ???낅┰ 諛뺤뒪 ???몄젒 zone ?곹뼢 X. ?먯쑀 諛곗튂 (gap / 寃뱀묠 ?ъ슜??梨낆엫).
|
|
// boundary drag 紐⑤뜽 (?몄젒 ?먮룞 議곗젙) ? grid 媛숈? 臾띠쓬 layout ???곹빀?쒕뜲,
|
|
// ?ъ슜???섎룄 = zone position drag 源뚯? ?먯쑀濡?쾶 ??independent 媛 ?먯뿰.
|
|
type ResizeDir =
|
|
| "top"
|
|
| "bottom"
|
|
| "left"
|
|
| "right"
|
|
| "nw"
|
|
| "ne"
|
|
| "sw"
|
|
| "se";
|
|
const makeResizeHandler = (
|
|
direction: ResizeDir
|
|
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
|
// resize ??pendingLayout OR image-zone ?몄쭛 紐⑤뱶 ?쒖꽦. 2026-05-22
|
|
// demo hot-fix ??frame partial ??@container aspect-ratio ?뚯쟾??
|
|
// ?ㅼ뼱媛??fixed px ?쒖빟 ?щ씪吏? IMP-90 u12: text/structure 紐⑤뱶
|
|
// ?먯꽌??zone resize 鍮꾪솢??(mutually exclusive per editGates).
|
|
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const startMouseX = ev.clientX;
|
|
const startMouseY = ev.clientY;
|
|
const startGeom: { x: number; y: number; w: number; h: number } =
|
|
overrideGeom ? { ...overrideGeom } : { ...localGeom };
|
|
const slideBodyWidthPx = W_SCALED * pendingSlideBody.w;
|
|
const slideBodyHeightPx = H_SCALED * pendingSlideBody.h;
|
|
const minSize = 0.05;
|
|
|
|
const affectsLeft = direction === "left" || direction === "nw" || direction === "sw";
|
|
const affectsRight = direction === "right" || direction === "ne" || direction === "se";
|
|
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
|
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
|
|
|
// 2026-05-22 demo hot-fix ??iframe ??留덉슦??媛濡쒖콈??mouseup leak ?쇱뼱??
|
|
// (?몄쭛 紐⑤뱶?먯꽌 iframe pointerEvents=auto). drag ?숈븞 iframe 媛뺤젣 none.
|
|
const iframeEl = iframeRef.current;
|
|
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
|
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
|
|
|
const onMove = (mv: MouseEvent) => {
|
|
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
|
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
|
let { x, y, w, h } = startGeom;
|
|
if (affectsRight) {
|
|
w = Math.max(minSize, Math.min(1 - startGeom.x, startGeom.w + dx));
|
|
}
|
|
if (affectsBottom) {
|
|
h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
|
|
}
|
|
if (affectsLeft) {
|
|
// 醫뚯륫 寃쎄퀎 ?대룞 ??w 以꾩씠怨?x ?섎┝ (?먮뒗 諛섎?)
|
|
const newW = Math.max(minSize, startGeom.w - dx);
|
|
x = Math.max(0, startGeom.x + (startGeom.w - newW));
|
|
w = newW;
|
|
}
|
|
if (affectsTop) {
|
|
const newH = Math.max(minSize, startGeom.h - dy);
|
|
y = Math.max(0, startGeom.y + (startGeom.h - newH));
|
|
h = newH;
|
|
}
|
|
onZoneResize({ [zone.zone_id]: { x, y, w, h } });
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
const handleResizeTop = makeResizeHandler("top");
|
|
const handleResizeBottom = makeResizeHandler("bottom");
|
|
const handleResizeLeft = makeResizeHandler("left");
|
|
const handleResizeRight = makeResizeHandler("right");
|
|
const handleResizeNW = makeResizeHandler("nw");
|
|
const handleResizeNE = makeResizeHandler("ne");
|
|
const handleResizeSW = makeResizeHandler("sw");
|
|
const handleResizeSE = makeResizeHandler("se");
|
|
|
|
// zone position drag ??pending mode ?먯꽌 zone body (handle ???곸뿭)
|
|
// mousedown drag 濡?x/y ?대룞. drag vs click 援щ텇 :
|
|
// 5px 誘몃쭔 ?대룞 = click ??onZoneClick
|
|
// 5px ?댁긽 = drag ??onZoneResize 濡?x/y 蹂寃?
|
|
const handleZoneMouseDown = (
|
|
ev: React.MouseEvent<HTMLDivElement>
|
|
) => {
|
|
ev.stopPropagation();
|
|
// IMP-90 u12: zone drag is image-zone-mode-only (text /
|
|
// structure suppress canDrag; non-zoneGestures click still
|
|
// triggers onZoneClick via the !dragged branch on mouse-up).
|
|
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && onZoneResize);
|
|
const startMouseX = ev.clientX;
|
|
const startMouseY = ev.clientY;
|
|
const startGeom = { ...localGeom };
|
|
const slideBodyWidthPx = canDrag
|
|
? W_SCALED * pendingSlideBody.w
|
|
: 1;
|
|
const slideBodyHeightPx = canDrag
|
|
? H_SCALED * pendingSlideBody.h
|
|
: 1;
|
|
let dragged = false;
|
|
|
|
// 2026-05-22 demo hot-fix ??same iframe pointer-events fix as makeResizeHandler.
|
|
const iframeEl = iframeRef.current;
|
|
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
|
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
|
|
|
const onMove = (mv: MouseEvent) => {
|
|
if (!canDrag) return;
|
|
const dxPx = mv.clientX - startMouseX;
|
|
const dyPx = mv.clientY - startMouseY;
|
|
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
|
|
dragged = true;
|
|
}
|
|
if (dragged) {
|
|
const { x: newX, y: newY } = clampZoneMove(
|
|
startGeom,
|
|
dxPx,
|
|
dyPx,
|
|
slideBodyWidthPx,
|
|
slideBodyHeightPx
|
|
);
|
|
onZoneResize!({
|
|
[zone.zone_id]: {
|
|
x: newX,
|
|
y: newY,
|
|
w: startGeom.w,
|
|
h: startGeom.h,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
|
if (!dragged) {
|
|
// ?⑥닚 click ?쇰줈 泥섎━ ??onZoneClick.
|
|
onZoneClick?.(zone.id);
|
|
}
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
// Step A : ?ъ슜?먭? ?ㅻⅨ frame ??怨⑤옄????洹?preview 瑜?zone ?덉뿉
|
|
// 諛섑닾紐낆쑝濡?誘몃━蹂닿린. final.html (iframe) ??default frame 怨??ㅻ? ?뚮쭔.
|
|
// override ?녾굅??default ? 媛숈쑝硫?誘몃━蹂닿린 X (iframe 怨?以묐났).
|
|
const region = zone.internal_regions[0];
|
|
const defaultFrameId = region?.frame_match_strategy.frame_id;
|
|
const overrideFrameId = region?.id
|
|
? userSelection.overrides.zone_frames?.[region.id]
|
|
: undefined;
|
|
const previewFrameId =
|
|
overrideFrameId && overrideFrameId !== defaultFrameId
|
|
? overrideFrameId
|
|
: null;
|
|
const previewCandidate = previewFrameId
|
|
? region?.frame_candidates?.find((c) => c.id === previewFrameId)
|
|
: null;
|
|
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
|
|
|
// IMP-11 u4: active frame lookup ??distinct axis from preview.
|
|
// preview is shown only when override differs from default; active is
|
|
// always defined as override-if-present-else-default. Used by u5 to
|
|
// compare the active frame's catalog min_height_px against zone height.
|
|
const activeFrameId = overrideFrameId ?? defaultFrameId;
|
|
const activeCandidate = activeFrameId
|
|
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
|
|
: undefined;
|
|
|
|
// IMP-11 u5: catalog min_height_px violation hint. height is already
|
|
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
|
|
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
|
|
// not re-multiplied (double-apply would shrink the comparison value).
|
|
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
|
|
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
|
|
const minHeightPx = activeCandidate?.minHeightPx ?? null;
|
|
const belowMinHeight =
|
|
isPendingLayout &&
|
|
minHeightPx != null &&
|
|
zoneHeightPx != null &&
|
|
zoneHeightPx < minHeightPx;
|
|
|
|
return (
|
|
<div
|
|
key={zone.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onMouseDown={handleZoneMouseDown}
|
|
onDragEnter={(ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
// 釉뚮씪?곗???custom dataTransfer type ??lowercase 媛뺤젣 ???묒そ ??check.
|
|
const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase());
|
|
if (types.includes("sectionid") || types.includes("section-id")) {
|
|
setDragOverZoneId(zone.id);
|
|
}
|
|
}}
|
|
onDragOver={(ev) => {
|
|
// drop target ?대젮硫?onDragOver ?먯꽌 preventDefault ?꾩닔 ??type check ?놁씠 臾댁“嫄??덉슜.
|
|
ev.preventDefault();
|
|
ev.dataTransfer.dropEffect = "move";
|
|
}}
|
|
onDragLeave={(ev) => {
|
|
// ?먯떇?쇰줈 ?ㅼ뼱媛硫댁꽌 諛쒖깮?섎뒗 leave 臾댁떆 ???ㅼ젣 leave 留?泥섎━.
|
|
if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
|
|
setDragOverZoneId((p) => (p === zone.id ? null : p));
|
|
}}
|
|
onDrop={(ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setDragOverZoneId(null);
|
|
// setData ?덈뜕 紐⑤뱺 type ???쒕룄 ??case 蹂??/ ?ㅻⅨ ??fallback.
|
|
const sectionId =
|
|
ev.dataTransfer.getData("sectionId") ||
|
|
ev.dataTransfer.getData("section-id") ||
|
|
ev.dataTransfer.getData("text/plain");
|
|
if (sectionId && onSectionDrop) {
|
|
onSectionDrop(sectionId, zone.zone_id);
|
|
}
|
|
}}
|
|
className={`absolute z-20 transition-all overflow-hidden ${
|
|
isEditMode
|
|
? "border border-dashed border-emerald-300/40 cursor-default"
|
|
: isPendingLayout
|
|
? "cursor-grab active:cursor-grabbing"
|
|
: "cursor-pointer"
|
|
} ${
|
|
isDragOver
|
|
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
|
: isSelected && isEditMode
|
|
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
|
: isSelected && !isEditMode
|
|
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
|
: !isEditMode
|
|
? "border border-dashed border-slate-300/40 hover:border-blue-400 hover:bg-blue-500/5"
|
|
: ""
|
|
}`}
|
|
style={{
|
|
left: `${x * 100}%`,
|
|
top: `${y * 100}%`,
|
|
width: `${width * 100}%`,
|
|
height: `${height * 100}%`,
|
|
// ?몄쭛 紐⑤뱶 : zone overlay ?먯껜???대┃ ?듦낵 (?띿뒪???몄쭛 ?곗꽑),
|
|
// resize handle 留?洹??덉뿉??pointer-events: auto.
|
|
pointerEvents: isEditMode ? "none" : "auto",
|
|
}}
|
|
title={
|
|
previewUrl
|
|
? `${sectionLabel} (zone: ${zone.zone_id}) - preview frame ${previewCandidate?.name}`
|
|
: `${sectionLabel} (zone: ${zone.zone_id}) - open frame panel or drop a section`
|
|
}
|
|
>
|
|
{/* Step A : ?좏깮 frame preview 誘몃━蹂닿린 (諛섑닾紐? iframe 怨?以묐났 X) */}
|
|
{previewUrl && (
|
|
<>
|
|
<img
|
|
src={previewUrl}
|
|
alt={`${zone.zone_id} preview`}
|
|
className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none"
|
|
/>
|
|
{/* "preview" 諛곗? ???곗긽??*/}
|
|
<span className="absolute top-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-amber-500 text-white shadow">
|
|
preview
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
|
|
is below the active frame's catalog min_height_px. Visual
|
|
hint only, no clamp/resize behavior change. */}
|
|
{belowMinHeight && minHeightPx != null && (
|
|
<>
|
|
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
|
|
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
|
|
min H {minHeightPx}px
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{/* zone ?쇰꺼 ??醫뚯긽?? 二??쇰꺼 = section ids (S1, S1+S2),
|
|
遺 ?쇰꺼 = backend zone position (top, bottom, primary). */}
|
|
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
|
<span
|
|
className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${
|
|
isSelected
|
|
? "bg-blue-600 text-white"
|
|
: isDragOver
|
|
? "bg-emerald-600 text-white"
|
|
: "bg-slate-900/80 text-white"
|
|
}`}
|
|
>
|
|
{sectionLabel}
|
|
</span>
|
|
<span className="text-[8px] font-bold uppercase tracking-tighter px-1 py-0.5 rounded bg-white/80 text-slate-500">
|
|
{zone.zone_id}
|
|
</span>
|
|
</div>
|
|
|
|
{/* drop hint ??drag over ??媛?대뜲???덈궡 */}
|
|
{isDragOver && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<span className="text-[12px] font-black uppercase tracking-wider px-3 py-1.5 rounded-full bg-emerald-500 text-white shadow-xl">
|
|
Drop to assign
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step C : zone resize handles ??8 諛⑺뼢. pendingLayout OR image-zone
|
|
?몄쭛 紐⑤뱶 ?쒖꽦. 2026-05-22 demo hot-fix ??frame partial ??@container
|
|
aspect-ratio ?뚯쟾 ?ㅼ뼱媛???fixed px ?쒖빟 ?щ씪??image-zone 紐⑤뱶 resize
|
|
???섎? ?덉쓬. IMP-90 u12: text / structure 紐⑤뱶?먯꽌??zone resize
|
|
affordance 誘몃끂異?(editGates.zoneGestures = image-zone only).
|
|
edge handle (top/bottom/left/right) : ??boundary ?대룞
|
|
corner handle (nw/ne/sw/se) : ??boundary ?숈떆. */}
|
|
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
|
<>
|
|
{/* top edge */}
|
|
<div
|
|
onMouseDown={handleResizeTop}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곷떒 boundary 議곗젅"
|
|
/>
|
|
{/* bottom edge */}
|
|
<div
|
|
onMouseDown={handleResizeBottom}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?섎떒 boundary 議곗젅"
|
|
/>
|
|
{/* left edge */}
|
|
<div
|
|
onMouseDown={handleResizeLeft}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚯륫 boundary 議곗젅"
|
|
/>
|
|
{/* right edge */}
|
|
<div
|
|
onMouseDown={handleResizeRight}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곗륫 boundary 議곗젅"
|
|
/>
|
|
{/* corner NW (醫뚯긽?? */}
|
|
<div
|
|
onMouseDown={handleResizeNW}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚯긽???숈떆 議곗젅"
|
|
/>
|
|
{/* corner NE (?곗긽?? */}
|
|
<div
|
|
onMouseDown={handleResizeNE}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곗긽???숈떆 議곗젅"
|
|
/>
|
|
{/* corner SW (醫뚰븯?? */}
|
|
<div
|
|
onMouseDown={handleResizeSW}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚰븯???숈떆 議곗젅"
|
|
/>
|
|
{/* corner SE (?고븯?? */}
|
|
<div
|
|
onMouseDown={handleResizeSE}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?고븯???숈떆 議곗젅"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
|
|
wrapper sets pointerEvents:none in edit mode (see above) to
|
|
preserve iframe text-edit clicks (A8 guardrail), so the
|
|
wrapper-level handleZoneMouseDown is unreachable in edit mode.
|
|
These 4 perimeter strips + top-left grip provide a separate
|
|
pointer-event surface routing into handleZoneMouseDown.
|
|
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
|
|
gesture wins in overlap regions, and ABOVE the iframe so the
|
|
strips intercept the perimeter while the un-covered iframe
|
|
interior keeps text-edit reachability intact.
|
|
pendingLayout mode already has wrapper pointerEvents:auto,
|
|
so these surfaces are only needed in edit mode.
|
|
IMP-90 u12: image-zone-mode-only ??text / structure 紐⑤뱶??
|
|
zone drag ????(editGates.zoneGestures = false ??紐⑤뱶 紐⑤몢). */}
|
|
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
|
<>
|
|
<div
|
|
onMouseDown={handleZoneMouseDown}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
|
title="Move zone"
|
|
/>
|
|
<div
|
|
onMouseDown={handleZoneMouseDown}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
|
title="Move zone"
|
|
/>
|
|
<div
|
|
onMouseDown={handleZoneMouseDown}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
|
title="Move zone"
|
|
/>
|
|
<div
|
|
onMouseDown={handleZoneMouseDown}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
|
title="Move zone"
|
|
/>
|
|
{/* visible grip affordance ??placed below the section label
|
|
(top-1 left-1 container) so the two don't overlap. */}
|
|
<div
|
|
onMouseDown={handleZoneMouseDown}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
|
style={{ pointerEvents: "auto", zIndex: 25 }}
|
|
title="Drag to move zone"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* IMP-90 (#90) u14 ??structure edit overlay (slot reorder +
|
|
hide). Renders only in `editMode === "structure"` over each
|
|
measured zone, positioned at the zone's top-right inside the
|
|
slide-absolute coord space. Slot keys come from u14 iframe
|
|
traversal (`slotKeysByZone`). Mutations emit through
|
|
onStructureEdit; u15 will debounce + PUT. */}
|
|
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
|
slidePlan?.zones.map((zone) => {
|
|
const m = measuredZones[zone.zone_id];
|
|
if (!m) return null;
|
|
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
|
const current = structureOverrides?.[zone.zone_id];
|
|
return (
|
|
<div
|
|
key={`struct-${zone.id}`}
|
|
className="absolute z-30"
|
|
style={{
|
|
left: m.x * W_SCALED,
|
|
top: m.y * H_SCALED,
|
|
width: m.w * W_SCALED,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<StructureEditOverlay
|
|
zoneId={zone.zone_id}
|
|
slotKeys={slotKeys}
|
|
current={current}
|
|
onChange={onStructureEdit}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* ?? IMP-51 (#79) u8 ??user-content image edit overlay ??
|
|
Activates only in edit mode when an image_id appears in either
|
|
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
|
|
(iframe-measured baseline). pendingLayout suppresses the image
|
|
overlay so zone editing and image editing never compete for the
|
|
same pointer events.
|
|
|
|
For every stamped user-content image we render a transparent
|
|
wrapper at the image's slide-absolute coords. Wrapper picks up
|
|
the body-drag gesture (move the image without resizing). When
|
|
the image is the `selectedImageId` we additionally render 8
|
|
resize handles. Aspect ratio is LOCKED on corner drags by
|
|
default; holding Shift during the drag unlocks it (matches the
|
|
issue contract "corner_resize_ratio_default_locked_shift_unlock").
|
|
|
|
Coordinate space: slide-absolute percent (0??00) throughout ??
|
|
measured / persisted / emitted values share the same units as
|
|
the u7 CSS injector (`left/top/width/height: {value}%`) and the
|
|
u3 typed-client `ImageOverride` contract. CSS values are
|
|
written verbatim ({geom.x}%, no scale factor) and pixel deltas
|
|
from MouseEvent are converted to percent via
|
|
`(dx_px / W_SCALED) * 100` so the round-trip drag ??save ??
|
|
re-render produces identical geometry. IMP-51 (#79) u9 moved
|
|
the resize / move math to `clampImagePercentGeometry` in
|
|
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
|
verified is exercised directly by vitest (mirror of how IMP-54
|
|
u3 split the zone math out of SlideCanvas). */}
|
|
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
|
|
structure 紐⑤뱶?먯꽌??image drag/resize affordance 誘몃끂異?
|
|
(editGates.imageOverlay = false). pendingLayout ???숈씪?섍쾶
|
|
suppress (computeEditModeGates 媛 紐⑤몢 false 諛섑솚). */}
|
|
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
|
|
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
|
([imageId]) => {
|
|
const persisted = imageOverrides?.[imageId];
|
|
const measured = measuredImages[imageId];
|
|
// override ?곗꽑; ?놁쑝硫?measured baseline. ?????놁쑝硫?skip.
|
|
const geom = persisted ?? measured;
|
|
if (!geom) return null;
|
|
const isSelected = selectedImageId === imageId;
|
|
|
|
const beginDrag = (
|
|
ev: React.MouseEvent<HTMLDivElement>,
|
|
direction: ImageDragDirection
|
|
) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setSelectedImageId(imageId);
|
|
const startMouseX = ev.clientX;
|
|
const startMouseY = ev.clientY;
|
|
const startGeom = { ...geom };
|
|
|
|
// 2026-05-22 demo hot-fix parity ??iframe ??留덉슦??媛濡?
|
|
// 梨꾩꽌 mouseup leak ?쇱뼱??(?몄쭛 紐⑤뱶?먯꽌 pe=auto).
|
|
const iframeEl = iframeRef.current;
|
|
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
|
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
|
|
|
const isCorner =
|
|
direction === "nw" ||
|
|
direction === "ne" ||
|
|
direction === "sw" ||
|
|
direction === "se";
|
|
|
|
const onMove = (mv: MouseEvent) => {
|
|
// Convert pixel delta on the on-screen scaled slide
|
|
// back into percent-of-slide so all downstream math
|
|
// shares the persisted axis's coord space. W_SCALED /
|
|
// H_SCALED already include the wrapper scale factor,
|
|
// so dividing then multiplying by 100 gives a stable
|
|
// value regardless of viewport zoom.
|
|
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
|
|
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
|
|
// IMP-51 (#79) u9 ??boundary contract lives in the
|
|
// pure helper so vitest can verify it directly.
|
|
// Aspect lock is default on for corner handles and
|
|
// released when Shift is held.
|
|
const aspectLocked = isCorner && !mv.shiftKey;
|
|
const next = clampImagePercentGeometry(
|
|
startGeom,
|
|
dx,
|
|
dy,
|
|
direction,
|
|
aspectLocked,
|
|
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
|
);
|
|
onImageResize(imageId, next);
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={`img-overlay-${imageId}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
data-image-overlay-id={imageId}
|
|
onMouseDown={(ev) => beginDrag(ev, "move")}
|
|
className={`absolute z-30 ${
|
|
isSelected
|
|
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
|
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
|
|
} cursor-grab active:cursor-grabbing`}
|
|
style={{
|
|
left: `${geom.x}%`,
|
|
top: `${geom.y}%`,
|
|
width: `${geom.w}%`,
|
|
height: `${geom.h}%`,
|
|
pointerEvents: "auto",
|
|
}}
|
|
title={
|
|
isSelected
|
|
? "?대?吏 ?대룞 ???쒕옒洹?/ 紐⑥꽌由??몃뱾 = ?ш린 (Shift = 鍮꾩쑉 ?댁젣)"
|
|
: "?대┃?섏뿬 ?좏깮"
|
|
}
|
|
>
|
|
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
|
|
IMG
|
|
</span>
|
|
|
|
{isSelected && (
|
|
<>
|
|
{/* edges */}
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "top")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곷떒"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "bottom")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?섎떒"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "left")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚯륫"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "right")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곗륫"
|
|
/>
|
|
{/* corners ??aspect locked by default, Shift unlocks */}
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "nw")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚯긽??(Shift = 鍮꾩쑉 ?댁젣)"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "ne")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?곗긽??(Shift = 鍮꾩쑉 ?댁젣)"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "sw")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="醫뚰븯??(Shift = 鍮꾩쑉 ?댁젣)"
|
|
/>
|
|
<div
|
|
onMouseDown={(ev) => beginDrag(ev, "se")}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
|
style={{ pointerEvents: "auto" }}
|
|
title="?고븯??(Shift = 鍮꾩쑉 ?댁젣)"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|