feat(#81): IMP-54 frontend zone editing UI (u1~u4 edit-mode body-drag + emerald highlight + pure drag-math helper + vitest)

u1: 4 perimeter edge strips (~8px) + top-left grip chip at zone wrapper
    provide an edit-mode pointer-event surface (zIndex 25) so wrapper-level
    handleZoneMouseDown becomes reachable in edit mode. Wrapper stays
    pointerEvents:none and iframe stays pointerEvents:auto to preserve
    text-edit reachability (A8 guardrail). Resize handles (z-30) win in
    overlap regions. Iframe pointer-events temporarily forced none during
    drag to prevent mouseup leak.
u2: Edit-mode isSelected branch reuses selectedZoneId with emerald visual
    (border-emerald-500 / bg-emerald-500/10) distinct from pendingLayout
    blue, decorative-only (pointerEvents:none inherits via wrapper rules).
u3: Pure drag math extracted to slideCanvasDragMath.ts — DRAG_THRESHOLD_PX,
    crossedDragThreshold(dx, dy) strict Math.hypot > 5, and clampZoneMove
    pixel→fraction conversion with x∈[0, 1-w] / y∈[0, 1-h] clamp.
    Resize math (makeResizeHandler) untouched.
u4: Vitest coverage (12 tests, 3 describe blocks) on the pure helper:
    threshold strict boundary at (3,4)/(5,0)/(0,5), above-threshold,
    negative-symmetric, clamp negative→0, max-edge → 1-w / 1-h, per-axis
    independence, non-square 500×250 slide-body, return-shape {x,y} only.

Stage 4 verify: pnpm exec vitest run client/src/components/slideCanvasDragMath.test.ts → 12/12 PASS.
Scope: edit-mode UX only. No HTML text modification, no automatic frame swap, no MDX touched.
Depends on: #9 IMP-09 (--override-zone-geometry backend wire), #80 IMP-52 (user_overrides.json zone_geometries persistence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:35:34 +09:00
co-authored by Claude Opus 4.7
parent 9388e25e76
commit bd8bcf748b
3 changed files with 259 additions and 19 deletions
@@ -0,0 +1,64 @@
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
//
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
// NOT touched — it has its own independent geometry model (per-side
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
// must not regress.
//
// Two responsibilities live here:
//
// 1. Drag-vs-click classification — a pointer must travel more than
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
// before mousedown→mousemove is treated as a drag. Below the
// threshold the gesture stays a click, which the caller surfaces as
// `onZoneClick(zone.id)` in `onUp`.
//
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
// moved zone fully inside the slide body. Width/height are preserved
// verbatim by this helper — only `x` and `y` move.
//
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
// drive them directly. The numeric contract is the inline behavior that
// existed before the extraction; this file is a relocation, not a behavior
// change.
export const DRAG_THRESHOLD_PX = 5;
/** 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 {
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
}
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
* Mirrors the shape the SlideCanvas pipeline already uses for
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
export interface ZoneFracGeom {
x: number;
y: number;
w: number;
h: number;
}
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
*
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
* `measuredSlideBody` precondition, so this helper does not re-guard
* divide-by-zero. */
export function clampZoneMove(
startGeom: ZoneFracGeom,
dxPx: number,
dyPx: number,
slideBodyWidthPx: number,
slideBodyHeightPx: number,
): { x: number; y: number } {
const dx = dxPx / slideBodyWidthPx;
const dy = dyPx / slideBodyHeightPx;
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
return { x, y };
}