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
@@ -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 {