feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

View File

@@ -22,9 +22,13 @@ import type {
NormalizedContent,
} from "../types/designAgent";
import {
IMAGE_RESIZE_MIN_SIZE_PERCENT,
clampImagePercentGeometry,
clampZoneMove,
crossedDragThreshold,
type ImageDragDirection,
} from "./slideCanvasDragMath";
import type { ImageOverridesOverride } from "../services/userOverridesApi";
interface SlideCanvasProps {
slidePlan: SlidePlan | null;
@@ -55,6 +59,24 @@ interface SlideCanvasProps {
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 0100). 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 (0100 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;
}
const SLIDE_W = 1280;
@@ -74,6 +96,8 @@ export default function SlideCanvas({
onSlideClick,
onSectionDrop,
onZoneResize,
imageOverrides,
onImageResize,
}: SlideCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
@@ -95,6 +119,24 @@ export default function SlideCanvas({
// 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 0100). 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 과 배타적 (충돌 방지).
@@ -122,6 +164,10 @@ export default function SlideCanvas({
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
let inputHandler: ((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 }> = [];
if (isEditMode) {
doc.designMode = "on";
doc.querySelectorAll(".slide *").forEach((el) => {
@@ -134,17 +180,49 @@ export default function SlideCanvas({
onContentEdit?.();
};
doc.addEventListener("input", inputHandler);
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
// in src/image_id_stamper.py (+ requires data-image-id which the
// stamper always emits). Decorative / frame imgs lacking the role
// attribute are intentionally NOT clickable here.
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 {
doc.designMode = "off";
doc.querySelectorAll("[contenteditable]").forEach((el) => {
(el as HTMLElement).removeAttribute("contenteditable");
});
// edit-mode exit also clears stale image selection so the handle
// overlay never lingers on a non-editable iframe.
setSelectedImageId(null);
}
return () => {
if (inputHandler && doc) {
doc.removeEventListener("input", inputHandler);
}
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
el.removeEventListener("click", handler);
el.style.cursor = prevCursor;
el.style.outline = prevOutline;
});
};
}, [isEditMode, finalHtmlUrl, onContentEdit]);
@@ -158,6 +236,10 @@ export default function SlideCanvas({
useEffect(() => {
setMeasuredZones({});
setMeasuredSlideBody(null);
// IMP-51 (#79) u8 — image measurements + selection are per-render;
// drop both so the new iframe's onLoad starts clean.
setMeasuredImages({});
setSelectedImageId(null);
}, [finalHtmlUrl]);
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
@@ -355,6 +437,33 @@ export default function SlideCanvas({
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 (0100) — image_overrides axis (u3
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
const imageEls = doc.querySelectorAll<HTMLImageElement>(
'.slide img[data-image-role="user-content"][data-image-id]'
);
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);
}
@@ -891,6 +1000,195 @@ export default function SlideCanvas({
</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 (0100) 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). */}
{!isPendingLayout && isEditMode && 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>

View File

@@ -13,8 +13,11 @@ import { describe, expect, it } from "vitest";
import {
DRAG_THRESHOLD_PX,
IMAGE_RESIZE_MIN_SIZE_PERCENT,
clampImagePercentGeometry,
clampZoneMove,
crossedDragThreshold,
type ImagePercentGeom,
type ZoneFracGeom,
} from "./slideCanvasDragMath";
@@ -105,3 +108,118 @@ describe("clampZoneMove", () => {
expect("h" in out).toBe(false);
});
});
// IMP-51 (#79) u9 — image overlay resize / move math.
// Boundary contract (must match the inline u8 math Codex #16 verified):
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
// including small-near-edge geoms where the existing minSize floor
// would otherwise have pushed past the slide bound.
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
// so a floor application never violates either axis.
// The two concrete Codex #15 reproductions are encoded explicitly below
// so a future regression on the boundary math fails this suite directly.
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
it("is 2 (percent of slide bbox)", () => {
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
});
});
describe("clampImagePercentGeometry", () => {
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
describe("direction = 'move'", () => {
it("translates and clamps both axes; preserves w/h", () => {
expect(
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
});
it("clamps negative deltas to (0, 0)", () => {
expect(
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
});
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
expect(
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
});
});
describe("edge resize — independent per-axis clamp", () => {
it("right edge clamps width to 100 - startGeom.x", () => {
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
});
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
// From Codex #15 / #16 verification — ordinary left drag past the
// slide edge should pin x at 0 and grow w by the original x amount.
expect(
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
});
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
expect(out.x + out.w).toBe(100);
});
it("top/bottom edges are symmetric to left/right", () => {
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
});
});
describe("corner resize — aspect locked (default Shift-off)", () => {
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
// From Codex #16 verification — aspect-locked NW past the slide
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
expect(
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
});
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
expect(out.x + out.w).toBeLessThanOrEqual(100);
expect(out.y + out.h).toBeLessThanOrEqual(100);
});
it("preserves baseAspect exactly when the floor is hit", () => {
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
// hits wFloor. newW/newH ratio must equal baseAspect.
const out = clampImagePercentGeometry(
baseGeom, -1000, -1000, "nw", true,
);
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
});
});
describe("corner resize — Shift unlock (independent edges)", () => {
it("SE without aspect lock degenerates to right + bottom edges", () => {
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
const sides = clampImagePercentGeometry(
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
0, 1000, "bottom", false,
);
expect(corner).toEqual(sides);
});
});
});

View File

@@ -25,6 +25,143 @@
export const DRAG_THRESHOLD_PX = 5;
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
// SlideCanvas.tsx `beginDrag` onMove (lines 10921219 of the u8 patch).
// Slide-absolute percent coordinate space (0100 on both axes), matching
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
// u3). The math is the contract Codex #16 verified post-u8 — this file
// is a relocation, not a behavior change. SlideCanvas calls it from a
// single hook so future tweaks need to update one place + the vitest
// suite alongside.
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
* shape moves end-to-end through stamper → overlay → persisted axis. */
export interface ImagePercentGeom {
x: number;
y: number;
w: number;
h: number;
}
export type ImageDragDirection =
| "move"
| "left"
| "right"
| "top"
| "bottom"
| "nw"
| "ne"
| "sw"
| "se";
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
*
* Contract (must match the inline u8 math Codex #16 verified):
* • `direction === "move"` → translate only; w/h preserved verbatim;
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
* independent edges (same per-edge clamp as above).
* • Corner handle with `aspectLocked=true` → preserves
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
* stays fixed; the floored axis is `w` and `h` is re-derived so the
* aspect ratio is exact even at the minSize floor.
*
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
* Pure / deterministic / no DOM access — vitest drives it directly. */
export function clampImagePercentGeometry(
startGeom: ImagePercentGeom,
dxPercent: number,
dyPercent: number,
direction: ImageDragDirection,
aspectLocked: boolean,
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
): ImagePercentGeom {
if (direction === "move") {
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
return { x, y, w: startGeom.w, h: startGeom.h };
}
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";
const isCorner =
direction === "nw" ||
direction === "ne" ||
direction === "sw" ||
direction === "se";
const rightEdge = startGeom.x + startGeom.w;
const bottomEdge = startGeom.y + startGeom.h;
let x = startGeom.x;
let y = startGeom.y;
let w = startGeom.w;
let h = startGeom.h;
if (affectsRight) {
const maxW = 100 - startGeom.x;
const floor = Math.min(minSize, maxW);
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
}
if (affectsBottom) {
const maxH = 100 - startGeom.y;
const floor = Math.min(minSize, maxH);
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
}
if (affectsLeft) {
const floor = Math.min(minSize, rightEdge);
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
w = rightEdge - x;
}
if (affectsTop) {
const floor = Math.min(minSize, bottomEdge);
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
h = bottomEdge - y;
}
if (isCorner && aspectLocked) {
const baseAspect =
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
if (baseAspect > 0) {
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
let newW = w;
let newH = newW / baseAspect;
if (newH > maxH) {
newH = maxH;
newW = newH * baseAspect;
}
if (newW > maxW) {
newW = maxW;
newH = newW / baseAspect;
}
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
if (newW < wFloor) {
newW = wFloor;
newH = newW / baseAspect;
}
w = newW;
h = newH;
x = affectsLeft ? rightEdge - w : startGeom.x;
y = affectsTop ? bottomEdge - h : startGeom.y;
}
}
return { x, y, w, h };
}
/** Returns true once the pointer has travelled far enough from the mousedown
* origin to be treated as a drag rather than a click. */
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {

View File

@@ -15,6 +15,7 @@ import {
getSelectedRegion,
moveSectionToZone,
saveZoneSizes,
saveImageOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
remapPersistedFramesToZoneFrames,
@@ -540,6 +541,37 @@ export default function Home() {
setHasPendingChanges(true);
}, []);
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
// in-memory `image_overrides` map via the u11 `saveImageOverride`
// helper so the immutable update path is shared with the test suite,
// • forward the full merged snapshot through `saveUserOverrides`
// (the u3 typed client) under the `image_overrides` key — the 300ms
// debounce defined alongside `zone_geometries` collapses the
// per-mousemove emits into one PUT at gesture-end,
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
// Coordinates are slide-absolute percent (0100) from u8/u9 — passed
// through unchanged so the on-disk schema matches the SlideCanvas
// overlay, the stamper selector (u4), and the render-time CSS
// injector (u7) without any per-zone transform.
const handleImageResize = useCallback(
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
setState((p) => {
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
image_overrides: nextSelection.overrides.image_overrides,
});
}
return { ...p, userSelection: nextSelection };
});
setHasPendingChanges(true);
},
[],
);
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
@@ -745,6 +777,8 @@ export default function Home() {
onSectionDrop={handleSectionDrop}
onLayoutResize={handleLayoutResize}
onZoneResize={handleZoneResize}
imageOverrides={state.userSelection.overrides.image_overrides}
onImageResize={handleImageResize}
/>
</main>

View File

@@ -7,6 +7,12 @@
// the four mutation handlers (u7). It does NOT own the schema — any change
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
//
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
// handles that drive this axis ride on u8~u11.
//
// Contract (Stage 2 unit u5 summary):
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
@@ -44,12 +50,28 @@ export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/**
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
* content image selector `.slide img[data-image-role="user-content"]`
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
* are slide-absolute percent (0100) so SlideCanvas drag handles (u8~u11)
* map 1:1 with the persisted axis without per-zone transforms.
*/
export type ImageOverride = {
x: number;
y: number;
w: number;
h: number;
};
export type ImageOverridesOverride = Record<string, ImageOverride>;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
frames: FramesOverride;
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */

View File

@@ -206,6 +206,13 @@ export interface UserSelection {
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0100
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
};
}

View File

@@ -72,6 +72,18 @@ export function applyPersistedNonFrameOverrides(
) {
next.zone_sections = { ...persisted.zone_sections };
}
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
// the same array / non-object guard the zone_geometries branch uses. The
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
// (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough.
if (
persisted.image_overrides &&
typeof persisted.image_overrides === "object" &&
!Array.isArray(persisted.image_overrides)
) {
next.image_overrides = { ...persisted.image_overrides };
}
return { ...selection, overrides: next };
}
@@ -143,13 +155,17 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
zone_sections: initialSections,
zone_sizes: {},
zone_geometries: {},
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
},
};
}
export function saveZoneGeometry(
selection: UserSelection,
zoneId: string,
selection: UserSelection,
zoneId: string,
geometry: { x: number; y: number; w: number; h: number }
): UserSelection {
return {
@@ -164,6 +180,32 @@ export function saveZoneGeometry(
};
}
/**
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
* handler (u8) emits one entry per pointer move, and u10's Home wiring
* funnels each emit through this helper before scheduling the debounced
* PUT. Pure / immutable — returns a fresh `UserSelection`; the input is
* never mutated. Existing entries for the same `imageId` are replaced.
*/
export function saveImageOverride(
selection: UserSelection,
imageId: string,
geometry: { x: number; y: number; w: number; h: number },
): UserSelection {
return {
...selection,
overrides: {
...selection.overrides,
image_overrides: {
...selection.overrides.image_overrides,
[imageId]: geometry,
},
},
};
}
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
return {
...selection,