From 505d0eb15a0c38ad454c29bf2d9fcccf38b85045 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 12:26:23 -0700 Subject: [PATCH 1/3] fix(studio): one GSAP-writes check that sees every keyframe form --- .../src/components/editor/cropResize.test.ts | 11 ++++ .../src/components/editor/cropResize.ts | 4 +- .../components/editor/gsapAnimatesProperty.ts | 54 ------------------- .../src/components/editor/manualEditsDom.ts | 4 +- .../editor/manualEditsSeekReapply.ts | 4 +- .../reapplyPositionEditsAfterSeek.test.ts | 25 +++++++++ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 3 +- 7 files changed, 44 insertions(+), 61 deletions(-) diff --git a/packages/studio/src/components/editor/cropResize.test.ts b/packages/studio/src/components/editor/cropResize.test.ts index b77901b842..78bf608705 100644 --- a/packages/studio/src/components/editor/cropResize.test.ts +++ b/packages/studio/src/components/editor/cropResize.test.ts @@ -93,6 +93,17 @@ describe("crop during a resize", () => { Object.assign(window, { __timelines: undefined }); }); + it("leaves a crop alone when GSAP tweens it by object-of-arrays keyframes", () => { + const tweened = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + const kf = { clipPath: ["inset(0px 60px 0px 0px)", "inset(0px 120px 0px 0px)"] }; + const child = { targets: () => [tweened], vars: { keyframes: kf } }; + Object.assign(window, { __timelines: { main: { getChildren: () => [child] } } }); + const stage = prepareCropResize(tweened); + tweened.style.width = "450px"; + expect(stage()).toBeNull(); + Object.assign(window, { __timelines: undefined }); + }); + it("decides before the write, which may add a width tween of its own", () => { const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); const stage = prepareCropResize(el); diff --git a/packages/studio/src/components/editor/cropResize.ts b/packages/studio/src/components/editor/cropResize.ts index 9d2e89e47e..6a3f4518d1 100644 --- a/packages/studio/src/components/editor/cropResize.ts +++ b/packages/studio/src/components/editor/cropResize.ts @@ -1,7 +1,7 @@ import type { PatchOperation } from "../../utils/sourcePatcher"; import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes"; import type { DomEditSelection } from "./domEditingTypes"; -import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes"; import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes"; import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers"; import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop"; @@ -21,7 +21,7 @@ type Follows = Record; /** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */ function cropFollows(element: HTMLElement): Follows { - const clip = gsapAnimatesProperty(element, "clipPath"); + const clip = gsapWritesChannels(element, ["clipPath"]); return { width: !clip && !elementHasNonHoldTween(element, ["width"]), height: !clip && !elementHasNonHoldTween(element, ["height"]), diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index 7b5aeb78c8..e0bb1fe64f 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,56 +1,2 @@ export const gsapRendersTransform = (el: Element) => !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform; - -/** - * Checks whether GSAP actively animates one or more CSS/GSAP properties on - * the given element by inspecting all registered `__timelines`. - */ -// fallow-ignore-next-line complexity -export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { - const win = el.ownerDocument.defaultView as - | (Window & { - __timelines?: Record< - string, - { - getChildren?: ( - deep: boolean, - ) => Array<{ targets?: () => Element[]; vars?: Record }>; - } - >; - }) - | null; - if (!win?.__timelines) return false; - const propSet = new Set(props); - for (const tl of Object.values(win.__timelines)) { - if (!tl?.getChildren) continue; - try { - for (const child of tl.getChildren(true)) { - if (!child.targets || !child.vars) continue; - let targetsEl = false; - for (const t of child.targets()) { - if (t === el || (el.id && t.id === el.id)) { - targetsEl = true; - break; - } - } - if (!targetsEl) continue; - const vars = child.vars; - for (const p of propSet) { - if (p in vars) return true; - } - if (vars.keyframes && typeof vars.keyframes === "object") { - for (const kfVal of Object.values(vars.keyframes as Record)) { - if (kfVal && typeof kfVal === "object") { - for (const p of propSet) { - if (p in (kfVal as Record)) return true; - } - } - } - } - } - } catch { - /* */ - } - } - return false; -} diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 5dd2f5224d..9a3ea22adf 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,7 +32,7 @@ import { STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, STUDIO_ROTATION_TRANSFORM_ORIGIN, } from "./manualEditsTypes"; -import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; import { roundTo3, roundToLayoutPx } from "../../utils/rounding"; import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches"; @@ -317,7 +317,7 @@ function applyStudioPathOffsetViaGsap( element: HTMLElement, offset: { x: number; y: number }, ): boolean { - if (!gsapAnimatesProperty(element, "x", "y")) return false; + if (!gsapWritesChannels(element, ["x", "y"])) return false; element.style.setProperty("translate", "none"); const win = element.ownerDocument.defaultView as | (Window & { diff --git a/packages/studio/src/components/editor/manualEditsSeekReapply.ts b/packages/studio/src/components/editor/manualEditsSeekReapply.ts index c38e1a599d..6782e7c2e3 100644 --- a/packages/studio/src/components/editor/manualEditsSeekReapply.ts +++ b/packages/studio/src/components/editor/manualEditsSeekReapply.ts @@ -12,7 +12,7 @@ import { import { applyStudioBoxSize, applyStudioPathOffset, applyStudioRotation } from "./manualEditsDom"; import { applyStudioMotionFromDom } from "./studioMotion"; import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes"; -import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes"; function queryStudioElements(doc: Document, attr: string): HTMLElement[] { const ctor = doc.defaultView?.HTMLElement; @@ -35,7 +35,7 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { // Unlike size below, the offset channels add up: applying both doubles the move. - if (gsapAnimatesProperty(el, "x", "y")) continue; + if (gsapWritesChannels(el, ["x", "y"])) continue; const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); if (!x && !y) continue; diff --git a/packages/studio/src/components/editor/reapplyPositionEditsAfterSeek.test.ts b/packages/studio/src/components/editor/reapplyPositionEditsAfterSeek.test.ts index 839aadd344..b40b9bb810 100644 --- a/packages/studio/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +++ b/packages/studio/src/components/editor/reapplyPositionEditsAfterSeek.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply"; import { + STUDIO_OFFSET_X_PROP, + STUDIO_OFFSET_Y_PROP, STUDIO_PATH_OFFSET_ATTR, STUDIO_ROTATION_ATTR, STUDIO_ROTATION_PROP, @@ -14,6 +16,29 @@ describe("reapplyPositionEditsAfterSeek", () => { vi.restoreAllMocks(); }); + it("keeps an old offset off an element GSAP moves by object-of-arrays keyframes, not off its neighbour", () => { + const offset = `${STUDIO_OFFSET_X_PROP}: 300px; ${STUDIO_OFFSET_Y_PROP}: 100px; translate: none`; + document.body.innerHTML = ["moved", "still"] + .map((id) => `
`) + .join(""); + const moved = document.getElementById("moved") as HTMLElement; + const tween = { targets: () => [moved], vars: { keyframes: { x: [0, 200] } } }; + const win = window as unknown as { __timelines?: unknown; gsap?: unknown }; + const gsap = { set: vi.fn(), getProperty: () => 0 }; + Object.assign(win, { __timelines: { main: { getChildren: () => [tween] } }, gsap }); + try { + reapplyPositionEditsAfterSeek(document); + } finally { + delete win.__timelines; + delete win.gsap; + } + + expect(moved.style.getPropertyValue("translate")).toBe("none"); + expect(gsap.set).not.toHaveBeenCalled(); + const still = document.getElementById("still") as HTMLElement; + expect(still.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + }); + it("does no per-edit work on a film Studio never edited", () => { document.body.innerHTML = '
'; const queryAll = vi.spyOn(document, "querySelectorAll"); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 038578768d..61cb651968 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -428,7 +428,8 @@ export const GSAP_TRANSFORM_KEYS = new Set( ); /** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */ -function gsapWritesChannels(el: Element, channels: string[]): boolean { +/** A tween or set on `el` writes one of `channels`, as a top-level var or in any keyframe form. */ +export function gsapWritesChannels(el: Element, channels: string[]): boolean { const win = el.ownerDocument.defaultView as { __timelines?: Record }; return Object.values(win?.__timelines ?? {}).some((tl) => (tl?.getChildren?.(true) ?? []).some( From 33ef0ef9e0e9d83f1265b1294f1694b1a4b8689b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 12:34:25 -0700 Subject: [PATCH 2/3] refactor(studio): keep the shared GSAP-writes check comment-free --- packages/studio/src/hooks/gsapRuntimeKeyframes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 61cb651968..53e721ab1c 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -428,7 +428,6 @@ export const GSAP_TRANSFORM_KEYS = new Set( ); /** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */ -/** A tween or set on `el` writes one of `channels`, as a top-level var or in any keyframe form. */ export function gsapWritesChannels(el: Element, channels: string[]): boolean { const win = el.ownerDocument.defaultView as { __timelines?: Record }; return Object.values(win?.__timelines ?? {}).some((tl) => From c4dd776c60965fea4b2a7fc7d8f5d01c6444723e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 12:48:50 -0700 Subject: [PATCH 3/3] refactor(studio): import the crop GSAP checks in one line --- packages/studio/src/components/editor/cropResize.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/cropResize.ts b/packages/studio/src/components/editor/cropResize.ts index 6a3f4518d1..306e984b07 100644 --- a/packages/studio/src/components/editor/cropResize.ts +++ b/packages/studio/src/components/editor/cropResize.ts @@ -1,8 +1,7 @@ import type { PatchOperation } from "../../utils/sourcePatcher"; import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes"; import type { DomEditSelection } from "./domEditingTypes"; -import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes"; -import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes"; +import { elementHasNonHoldTween, gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes"; import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers"; import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop"; import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";