85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
213 lines
7.4 KiB
TypeScript
213 lines
7.4 KiB
TypeScript
import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
||
|
||
export type CropEdge = "top" | "right" | "bottom" | "left";
|
||
|
||
export interface CropScreenRect {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
/** Element-space insets → the cropped region in overlay (screen) space. */
|
||
export function cropRectFromInsets(
|
||
rect: CropScreenRect,
|
||
insets: ClipPathInsetSides,
|
||
scaleX: number,
|
||
scaleY: number,
|
||
): CropScreenRect {
|
||
const sx = scaleX > 0 ? scaleX : 1;
|
||
const sy = scaleY > 0 ? scaleY : 1;
|
||
const left = rect.left + insets.left * sx;
|
||
const top = rect.top + insets.top * sy;
|
||
return {
|
||
left,
|
||
top,
|
||
width: Math.max(0, rect.width - (insets.left + insets.right) * sx),
|
||
height: Math.max(0, rect.height - (insets.top + insets.bottom) * sy),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Current inset crop of an element (inline first, computed fallback).
|
||
* Zeros = no clip (croppable, nothing cropped yet). `null` = the element
|
||
* carries a clip-path this tool cannot represent (circle/polygon/non-px
|
||
* inset) — croppers must not lift, edit, or restore it, or the clip gets
|
||
* silently replaced or destroyed on deselect.
|
||
*/
|
||
export function readElementCropInsets(
|
||
element: HTMLElement,
|
||
): (ClipPathInsetSides & { radius: number }) | null {
|
||
const inline = element.style.getPropertyValue("clip-path").trim();
|
||
const value =
|
||
inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
|
||
if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
|
||
return parseInsetClipPathSides(value);
|
||
}
|
||
|
||
export interface CropInsetDragInput {
|
||
edge: CropEdge;
|
||
startInsets: ClipPathInsetSides;
|
||
deltaX: number;
|
||
deltaY: number;
|
||
scaleX: number;
|
||
scaleY: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
function clampInset(value: number, max: number): number {
|
||
if (!Number.isFinite(value)) return 0;
|
||
return Math.min(Math.max(0, value), Math.max(0, max));
|
||
}
|
||
|
||
export function resolveCropInsetFromEdgeDrag(input: CropInsetDragInput): ClipPathInsetSides {
|
||
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
|
||
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
|
||
const next = { ...input.startInsets };
|
||
|
||
if (input.edge === "left") {
|
||
next.left = clampInset(
|
||
input.startInsets.left + input.deltaX / scaleX,
|
||
input.width - next.right,
|
||
);
|
||
} else if (input.edge === "right") {
|
||
next.right = clampInset(
|
||
input.startInsets.right - input.deltaX / scaleX,
|
||
input.width - next.left,
|
||
);
|
||
} else if (input.edge === "top") {
|
||
next.top = clampInset(
|
||
input.startInsets.top + input.deltaY / scaleY,
|
||
input.height - next.bottom,
|
||
);
|
||
} else {
|
||
next.bottom = clampInset(
|
||
input.startInsets.bottom - input.deltaY / scaleY,
|
||
input.height - next.top,
|
||
);
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
/** Pan the crop window: opposing insets shift together so the crop size stays
|
||
* constant, clamped inside the element bounds. Repositions which part of the
|
||
* element shows through a fixed-size crop (the center "reposition" handle). */
|
||
export function resolveCropInsetFromMoveDrag(input: {
|
||
startInsets: ClipPathInsetSides;
|
||
deltaX: number;
|
||
deltaY: number;
|
||
scaleX: number;
|
||
scaleY: number;
|
||
}): ClipPathInsetSides {
|
||
const sx = input.scaleX > 0 ? input.scaleX : 1;
|
||
const sy = input.scaleY > 0 ? input.scaleY : 1;
|
||
const totalX = input.startInsets.left + input.startInsets.right;
|
||
const totalY = input.startInsets.top + input.startInsets.bottom;
|
||
const left = Math.min(Math.max(0, input.startInsets.left + input.deltaX / sx), totalX);
|
||
const top = Math.min(Math.max(0, input.startInsets.top + input.deltaY / sy), totalY);
|
||
return { left, right: totalX - left, top, bottom: totalY - top };
|
||
}
|
||
|
||
/** Display-only hug: shrink a projected rect by the element's inset crop.
|
||
* For rects nothing writes back to (e.g. the hover ring). */
|
||
export function hugRectForElement(
|
||
rect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
||
element: HTMLElement,
|
||
): CropScreenRect {
|
||
const insets = readElementCropInsets(element);
|
||
// Uneditable clip (null) can't be hugged — show the full element rect.
|
||
if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
|
||
return rect;
|
||
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
|
||
}
|
||
|
||
/**
|
||
* The element's own (unrotated) box in overlay space, plus the rotation to
|
||
* apply when drawing crop UI over it. `clip-path` applies in the element's
|
||
* LOCAL frame — before its transform — so the crop dim/outline/handles must be
|
||
* drawn rotated with the element, not on its axis-aligned bounding box: an
|
||
* AABB-drawn dim visually "straightens" a rotated element by masking its
|
||
* corners (the crop window looks axis-aligned while the pixels are not).
|
||
*
|
||
* scaleX/scaleY are overlay px per element CSS px (element's own scale × the
|
||
* editor zoom), so element-space insets map straight onto the frame. Assumes
|
||
* the default 50%/50% transform-origin (the GSAP/studio convention). 3D or
|
||
* unparseable transforms fall back to the axis-aligned frame (angle 0, AABB
|
||
* box) — the pre-existing presentation.
|
||
*/
|
||
export interface CropFrame {
|
||
angleDeg: number;
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
scaleX: number;
|
||
scaleY: number;
|
||
}
|
||
|
||
export function readElementCropFrame(
|
||
element: HTMLElement,
|
||
overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
||
): CropFrame {
|
||
const editX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
||
const editY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
||
const aabb: CropFrame = {
|
||
angleDeg: 0,
|
||
left: overlayRect.left,
|
||
top: overlayRect.top,
|
||
width: overlayRect.width,
|
||
height: overlayRect.height,
|
||
scaleX: editX,
|
||
scaleY: editY,
|
||
};
|
||
let transform = "";
|
||
try {
|
||
transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
|
||
} catch {
|
||
return aabb;
|
||
}
|
||
if (!transform || transform === "none") return aabb;
|
||
const m = /^matrix\(([^)]+)\)$/.exec(transform);
|
||
if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
|
||
const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
|
||
if (![a, b, c, d].every(Number.isFinite)) return aabb;
|
||
const elScaleX = Math.hypot(a!, b!);
|
||
const det = a! * d! - b! * c!;
|
||
const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
|
||
if (elScaleX <= 0 || elScaleY <= 0) return aabb;
|
||
const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
|
||
const scaleX = elScaleX * editX;
|
||
const scaleY = elScaleY * editY;
|
||
const width = element.offsetWidth * scaleX;
|
||
const height = element.offsetHeight * scaleY;
|
||
if (!(width > 0) || !(height > 0)) return aabb;
|
||
// Rotation about the default center keeps the center invariant, so the
|
||
// local box is centered on the AABB center.
|
||
const cx = overlayRect.left + overlayRect.width / 2;
|
||
const cy = overlayRect.top + overlayRect.height / 2;
|
||
return {
|
||
angleDeg,
|
||
left: cx - width / 2,
|
||
top: cy - height / 2,
|
||
width,
|
||
height,
|
||
scaleX,
|
||
scaleY,
|
||
};
|
||
}
|
||
|
||
/** Rotate a screen-space pointer delta into the element's local frame. */
|
||
export function rotateDeltaIntoFrame(
|
||
deltaX: number,
|
||
deltaY: number,
|
||
angleDeg: number,
|
||
): { deltaX: number; deltaY: number } {
|
||
if (angleDeg === 0) return { deltaX, deltaY };
|
||
const rad = (-angleDeg * Math.PI) / 180;
|
||
const cos = Math.cos(rad);
|
||
const sin = Math.sin(rad);
|
||
return { deltaX: deltaX * cos - deltaY * sin, deltaY: deltaX * sin + deltaY * cos };
|
||
}
|