mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
* fix(studio): read the rotate property when measuring an element's angle Turning an element with Studio's rotate handle left every piece of overlay chrome square across it: the selection box, the crop outline and the child outlines all drew upright while the element underneath was clearly rotated. The handle writes the CSS `rotate` property. `rotate` is an individual transform property, not part of `transform`, so `getComputedStyle(el).transform` reports nothing for it and both places that measure an element's angle — the overlay geometry and the crop frame — read the element as upright. Both now read `rotate` alongside `transform` and compose them the way CSS does, individual properties first. A rotation about any axis but z has no single in-plane angle, so it reports nothing and the caller keeps its axis-aligned fallback rather than drawing chrome at a plausible wrong angle. * fix(studio): stop the crop outline refusing the transforms GSAP writes The crop outline still drew square on a rotated element after the rotate- property fix, because it refused the transform outright: it accepted only `matrix(...)`, and GSAP writes `matrix3d(...)` for an ordinary 2D move or spin (force3D). A composition that mirrors an element writes one with a negative z scale, and the negative determinant that follows was refused too. Both are ordinary planar transforms. The outline now reads the same 2D projection the rest of the chrome takes through DOMMatrix, and sizes a flipped element from the magnitude of its determinant. Only a perspective term still falls back, because that is where the mapping stops being affine and no single angle describes it. The test that asserted "a 3D matrix means give up" asserted the bug: its fixture was the identity written as matrix3d, which is as planar as a transform gets. It now checks the behaviour that replaced it, alongside the perspective case, which still falls back. * fix(studio): draw the crop outline at the angle the element paints under Selecting a text layer inside a rotated card drew its crop outline across the text at roughly a right angle. The outline read the element's own transform, but what the user sees is that composed with every ancestor's — the layer carries its own spin and its parent turns it again. It now walks to the composition root and composes each level, the element's `rotate` property before its `transform` and an ancestor outside its child, which is the order CSS applies them in. Nothing transformed anywhere still falls back to the caller's axis-aligned rect, since that comes from real layout and describes the element exactly. The chrome test stubbed getComputedStyle to answer "rotated 30deg" for every node in the document, so composing read the same turn once per ancestor. The stub now answers per element, which is what it always meant. * fix(studio): stop the dev server reloading the page on every canvas edit A composition lives under this package's root, so Vite's HMR saw a write to one as an html page dependency changing and full-reloaded the browser. That reload is the flash after every edit in the canvas: the whole app remounts, taking the preview iframe with it. The decision was never Vite's to make. Studio already knows whether a write was its own — that is what the write receipt is for — and refreshes the preview itself when it needs to. Vite's watcher now ignores the project data, and the dev plugin watches it on a watcher of its own, announcing changes as hf:file-change exactly as before. Measured on a drag: Vite full reloads went from one per edit to none, and the receipt now reports 'suppressed: own write token' where it previously never saw a matching path. * refactor(studio): compose an element's transform in one walk, not two Review: the crop frame hand-composed ancestor matrices while the geometry file did the same walk through DOMMatrix. Both were right, but the next individual transform property CSS grows — `translate`, `scale` — would have to land in both, and a miss puts the crop outline back at the wrong angle while the selection box draws the right one. The walk now lives in one place and takes the arithmetic as a parameter. The geometry file keeps DOMMatrix, because it goes on to transform corner points and needs the translation; the crop frame keeps plain 2D components, because it only needs an angle and a scale. Which transforms count, and in what order, is stated once. Also from review: the nested case was verified by hand only, so the composed walk is now covered on both sides — a child inside a rotated parent reports the angle it paints at, the parent's rotation alone when the child has none, and the walk stopping at the composition root. And `hasAttribute?.` was dead on a narrowed HTMLElement; it only survived because the crop test's fake element was not one. The fake now models an element and the guard is gone. * style(studio): format the shared transform module
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
/**
|
|
* One walk from an element up to its composition root, composing the transform
|
|
* it actually paints under.
|
|
*
|
|
* The overlay measures an element's angle in two places — the selection box and
|
|
* the crop frame — and they need different arithmetic: one works in DOMMatrix
|
|
* because it goes on to transform corner points, the other in plain 2D
|
|
* components because it only needs an angle and a scale. What they must never
|
|
* differ on is *which* transforms count and in what order, because when they
|
|
* disagree the chrome disagrees with itself: the selection box drawn at one
|
|
* angle and the crop outline at another, on the same element.
|
|
*
|
|
* So the walk lives here once and takes the arithmetic as a parameter. Adding
|
|
* an individual property CSS grew later — `translate`, `scale` — means adding
|
|
* one step here and one method to each algebra, rather than finding both walks
|
|
* and hoping.
|
|
*/
|
|
|
|
/** The composition's own root; the walk stops there rather than at the document. */
|
|
const COMPOSITION_ROOT_ATTR = "data-composition-id";
|
|
|
|
/**
|
|
* The arithmetic the walk needs, whatever the caller's matrix type is.
|
|
*
|
|
* `fromTransform` returns null for a transform the caller cannot use — a
|
|
* perspective matrix, say — which aborts the walk rather than composing a
|
|
* matrix that describes something other than what is painted.
|
|
*/
|
|
export interface PlanarTransformOps<M> {
|
|
identity(): M;
|
|
fromTransform(value: string): M | null;
|
|
fromRotate(degrees: number): M;
|
|
/** `outer` applied around `inner`, as an ancestor composes over a child. */
|
|
compose(outer: M, inner: M): M;
|
|
}
|
|
|
|
/**
|
|
* The planar rotation in the CSS `rotate` property, in degrees.
|
|
*
|
|
* Computes to `none`, an angle (`-22deg`), or an axis plus an angle
|
|
* (`0 0 1 -22deg`). Only a rotation about z stays in the overlay's plane; any
|
|
* other axis is 3D and reports 0, which leaves the caller on its axis-aligned
|
|
* fallback rather than drawing a box at a plausible-looking wrong angle.
|
|
*/
|
|
export function individualRotateDegrees(value: string | undefined): number {
|
|
if (!value || value === "none") return 0;
|
|
const parts = value.trim().split(/\s+/);
|
|
const angle = parts.at(-1);
|
|
if (!angle?.endsWith("deg")) return 0;
|
|
if (parts.length === 4) {
|
|
const [x, y, z] = parts;
|
|
if (Number(x) !== 0 || Number(y) !== 0 || Math.abs(Number(z)) !== 1) return 0;
|
|
const deg = Number.parseFloat(angle);
|
|
return Number.isFinite(deg) ? deg * Math.sign(Number(z)) : 0;
|
|
}
|
|
if (parts.length !== 1) return 0;
|
|
const deg = Number.parseFloat(angle);
|
|
return Number.isFinite(deg) ? deg : 0;
|
|
}
|
|
|
|
/**
|
|
* The element's transform composed with every ancestor's, up to the composition
|
|
* root.
|
|
*
|
|
* Within a node, CSS applies the individual properties before `transform`, so
|
|
* `rotate` composes on the left of it. Between nodes, an ancestor applies
|
|
* outside its child. Null means some node's transform was unusable and the
|
|
* caller should fall back rather than guess.
|
|
*/
|
|
export function composeElementTransform<M>(
|
|
element: HTMLElement,
|
|
ops: PlanarTransformOps<M>,
|
|
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
|
|
): M | null {
|
|
let acc = ops.identity();
|
|
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
|
|
const style = getStyle(node);
|
|
if (!style) return null;
|
|
const transform = style.transform;
|
|
let own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
|
|
if (!own) return null;
|
|
const spin = individualRotateDegrees(style.rotate);
|
|
if (spin !== 0) own = ops.compose(ops.fromRotate(spin), own);
|
|
acc = ops.compose(own, acc);
|
|
if (node.hasAttribute(COMPOSITION_ROOT_ATTR)) break;
|
|
}
|
|
return acc;
|
|
}
|