feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)
This commit is contained in:
@@ -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 0–100). 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–100 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 0–100). 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 (0–100) — 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 (0–100) 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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 1092–1219 of the u8 patch).
|
||||
// Slide-absolute percent coordinate space (0–100 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 {
|
||||
|
||||
@@ -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 (0–100) 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>
|
||||
|
||||
|
||||
@@ -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 (0–100) 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). */
|
||||
|
||||
@@ -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 (0–100
|
||||
// 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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user