fix(studio): per-property-group intercept routing + drag/resize fixes (#1356)

* fix(core): per-property-group keyframe foundations

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.

* fix(core): add split-into-property-groups and replace-with-keyframes mutations

Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.

* fix(studio): per-property-group intercept routing + drag/resize fixes

Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
This commit is contained in:
Miguel Ángel
2026-06-12 00:19:13 -04:00
committed by GitHub
parent b6bf1b1190
commit 95029e083e
12 changed files with 503 additions and 176 deletions
@@ -32,7 +32,7 @@ import {
} from "./manualEditsTypes";
import { roundRotationAngle } from "./manualEditsParsing";
import { applyStudioMotionFromDom } from "./studioMotion";
import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty";
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;
@@ -223,7 +223,6 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
}
// fallow-ignore-next-line complexity
function stripGsapTranslateFromTransform(element: HTMLElement): void {
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
const transform = element.style.getPropertyValue("transform");
@@ -258,18 +257,6 @@ export function applyStudioPathOffset(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
if (gsapAnimatesTransform(element)) {
// GSAP folded the CSS translate into its transform cache at init and owns
// style.transform from then on — it zeroes the translate longhand exactly
// once (at fold time) and never re-reads it. Writing translate here would
// double-apply the offset on top of the baked transform. Keep translate
// neutral in the live DOM and push the offset into GSAP's cache instead;
// the var() expression is persisted to the source file by the patch
// builder, where a reload re-folds it.
element.style.setProperty("translate", "none");
syncGsapOwnedTransformPosition(element);
return;
}
element.style.setProperty(
"translate",
composeTranslateValue(
@@ -281,24 +268,6 @@ export function applyStudioPathOffset(
stripGsapTranslateFromTransform(element);
}
/**
* After committing a new path offset on an element whose transform GSAP owns,
* GSAP's internal cache still holds the pre-drag baked translate — the next
* seek re-renders from that cache and snaps the element back. Push the new
* offset into GSAP so live scrubbing matches what was persisted. (A page
* reload re-initializes GSAP from the persisted CSS translate, so this is
* only needed for the live session.)
*/
function syncGsapOwnedTransformPosition(element: HTMLElement): void {
if (!gsapAnimatesTransform(element)) return;
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
| null;
if (!win?.gsap?.set) return;
const { x, y } = readStudioPathOffset(element);
win.gsap.set(element, { x, y });
}
export function applyStudioPathOffsetDraft(
element: HTMLElement,
offset: { x: number; y: number },
@@ -306,16 +275,36 @@ export function applyStudioPathOffsetDraft(
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false });
const isGsapAnimated = gsapAnimatesTransform(element);
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
if (isGsapAnimated) {
// GSAP owns style.transform (see applyStudioPathOffset): position via
// gsap.set while the timeline is paused. Set translate:none explicitly to
// prevent double-counting with the baked transform.
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
| (Window & {
gsap?: {
set: (el: Element, vars: Record<string, unknown>) => void;
getProperty: (el: Element, prop: string) => number;
};
})
| null;
win?.gsap?.set(element, { x: offset.x, y: offset.y });
if (win?.gsap) {
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
const gsapBaseX = Number.isFinite(baseX)
? baseX
: (win.gsap.getProperty(element, "x") as number);
const gsapBaseY = Number.isFinite(baseY)
? baseY
: (win.gsap.getProperty(element, "y") as number);
if (!Number.isFinite(baseX))
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
if (!Number.isFinite(baseY))
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
}
} else {
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
@@ -551,14 +540,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
function reapplyPathOffsets(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
// Skip elements where GSAP owns the transform stack — GSAP bakes the
// CSS translate into its transform and sets translate: none every tick
// when it tweens ANY transform property (x/y, scale, rotation, ...).
// Stripping/restoring would oscillate against GSAP's rendering and
// double-apply the offset.
if (gsapAnimatesTransform(el)) continue;
const gsapSkip = gsapAnimatesProperty(el, "x", "y");
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (gsapSkip) continue;
if (x || y) {
applyStudioPathOffset(
el,
@@ -4,7 +4,6 @@ import {
styleUsesStudioRotation,
restoreInlineDisplay,
} from "./manualEditsDom";
import { gsapAnimatesTransform } from "./gsapAnimatesProperty";
import {
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
@@ -88,23 +87,7 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps
};
}
type GsapWindow = Window & {
gsap?: {
getProperty?: (el: Element, prop: string) => number | string;
set?: (el: Element, vars: Record<string, unknown>) => void;
};
};
export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot {
let gsapX: number | null = null;
let gsapY: number | null = null;
if (gsapAnimatesTransform(element)) {
const win = element.ownerDocument.defaultView as GsapWindow | null;
if (win?.gsap?.getProperty) {
gsapX = Number(win.gsap.getProperty(element, "x")) || 0;
gsapY = Number(win.gsap.getProperty(element, "y")) || 0;
}
}
return {
translate: element.style.getPropertyValue("translate"),
x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP),
@@ -112,8 +95,6 @@ export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetS
marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR),
originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR),
originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR),
gsapX,
gsapY,
};
}
@@ -203,11 +184,20 @@ export function restoreStudioPathOffset(
previous.originalInlineTranslate,
);
// Draft positioning on GSAP-owned elements goes through gsap.set, which
// mutates GSAP's transform cache — restore it alongside the inline styles.
if (previous.gsapX != null || previous.gsapY != null) {
const win = element.ownerDocument.defaultView as GsapWindow | null;
win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 });
// Restore GSAP x/y if a draft was applied via gsap.set during drag
const baseX = element.getAttribute("data-hf-drag-gsap-base-x");
const baseY = element.getAttribute("data-hf-drag-gsap-base-y");
if (baseX != null || baseY != null) {
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
| null;
if (win?.gsap) {
const x = Number.parseFloat(baseX ?? "0") || 0;
const y = Number.parseFloat(baseY ?? "0") || 0;
win.gsap.set(element, { x, y });
}
element.removeAttribute("data-hf-drag-gsap-base-x");
element.removeAttribute("data-hf-drag-gsap-base-y");
}
}
@@ -101,12 +101,4 @@ export interface StudioPathOffsetSnapshot {
marker: string | null;
originalTranslate: string | null;
originalInlineTranslate: string | null;
/**
* GSAP's cached x/y at capture time, for elements whose transform GSAP
* owns. Draft positioning mutates GSAP's cache (gsap.set), which inline
* style restoration alone cannot undo. Null when GSAP does not own the
* element's transform.
*/
gsapX: number | null;
gsapY: number | null;
}