From fc3ab76ce80277e6b4832b85d875a2f14c1f28b6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 12:30:14 -0700 Subject: [PATCH] fix(studio,core): persist manual position edits for GSAP-owned elements (#1346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) * fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 * fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline - applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 * fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs Round-2 review (Rames/Miguel) on the engine layer: - ORIGIN_APPLY_PATCHES: unique symbol → namespaced string ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't survive postMessage/structured-clone, which T3 embedded hosts may forward patch events across. Namespaced string keeps collision risk negligible. - setCompositionMetadata width/height: runtime treats data-width/data-height as a forced override of inline style (init.ts applyCompositionSizing). Style is always written; the data-* attr is updated when already present so the edit isn't clobbered on load. Absent attrs stay absent — inverses stay exact. Mirrored in the patch applier; 3 new tests. - JsonPatchOp documented as the emit-only RFC 6902 subset (add/remove/replace); applier header notes move/copy/test are ignored. - SdkDocument.html documented as a build-time snapshot (serialize() is the live state). - patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}. NOT changed (with reasons, see PR reply): moveElement left/top matches Studio's own inline-style commit convention (sourcePatcher); package version follows the repo-wide single-version policy. Co-Authored-By: Claude Fable 5 * fix(sdk): moveElement writes data-x/data-y, not left/top CSS HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 * feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 * feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting * fix(studio,core): persist manual position edits for GSAP-owned elements - sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom properties and transform longhands via setProperty; patch the style attribute string directly so --hf-studio-offset-* and translate survive the server round-trip (positions never reached disk before this) - gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS translate into its cache once at init, zeroes the longhand once, and never re-reads it - applyStudioPathOffset: for GSAP-owned elements keep translate:none live and sync the offset into GSAP's cache via gsap.set; writing the longhand double-applied the offset (disappearing elements, scrub snap-back) - buildPathOffsetPatches: emit the var() translate expression explicitly so the persisted file re-folds on reload (live inline is none) - StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response probe mutates GSAP's cache, which inline-style restore cannot undo (click made elements jump by the probe distance) - reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop seek-time double-apply - STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is opt-in until its recording path is hardened; commits take the CSS persist path Co-Authored-By: Claude Fable 5 * fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Fable 5 --- .../helpers/manualEditsRenderScript.ts | 1 + .../src/studio-api/helpers/sourceMutation.ts | 39 +++++++++++++-- .../components/editor/gsapAnimatesProperty.ts | 30 ++++++++++++ .../editor/manualEditingAvailability.ts | 16 +++--- .../src/components/editor/manualEditsDom.ts | 49 ++++++++++++++++--- .../editor/manualEditsDomPatches.test.ts | 1 + .../editor/manualEditsDomPatches.ts | 18 ++++++- .../components/editor/manualEditsSnapshot.ts | 26 ++++++++++ .../src/components/editor/manualEditsTypes.ts | 8 +++ .../studio/src/contexts/DomEditContext.tsx | 1 + .../studio/src/hooks/useDomEditCommits.ts | 8 ++- .../studio/src/hooks/useDomEditSession.ts | 8 +-- 12 files changed, 182 insertions(+), 23 deletions(-) diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index f5b57f9f1..2d6dd39aa 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication export interface StudioManualEditsRenderScriptOptions { activeCompositionPath?: string | null; } diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index eeb78a266..c8a471c0e 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -223,6 +223,33 @@ function isSafeAttributeValue(name: string, value: string): boolean { return true; } +function patchStyleAttrString(style: string, property: string, value: string | null): string { + const props = new Map(); + const order: string[] = []; + for (const decl of style.split(";")) { + const colon = decl.indexOf(":"); + if (colon < 0) continue; + const key = decl.slice(0, colon).trim(); + const val = decl.slice(colon + 1).trim(); + if (!key) continue; + if (!props.has(key)) order.push(key); + props.set(key, val); + } + if (value === null) { + props.delete(property); + const idx = order.indexOf(property); + if (idx >= 0) order.splice(idx, 1); + } else { + if (!props.has(property)) order.push(property); + props.set(property, value); + } + return order + .map((k) => `${k}: ${props.get(k) ?? ""}`) + .filter((d) => d.trim()) + .join("; "); +} + +// fallow-ignore-next-line complexity export function patchElementInHtml( source: string, target: SourceMutationTarget, @@ -236,10 +263,14 @@ export function patchElementInHtml( for (const op of operations) { switch (op.type) { case "inline-style": - if (op.value != null) { - htmlEl.style.setProperty(op.property, op.value); - } else { - htmlEl.style.removeProperty(op.property); + // linkedom's CSSStyleDeclaration does not support CSS custom properties + // (--foo) or newer individual transform properties (translate, rotate, + // scale) via style.setProperty(). Manipulate the style attribute string + // directly so all property names survive the round-trip. + { + const raw = htmlEl.getAttribute("style") ?? ""; + const patched = patchStyleAttrString(raw, op.property, op.value); + htmlEl.setAttribute("style", patched); } break; case "attribute": diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index a704862c4..9485c410c 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,7 +1,37 @@ +// GSAP's CSSPlugin takes ownership of the element's entire transform stack +// when it tweens ANY of these — it bakes the CSS `translate` longhand into +// style.transform at init and writes `translate: none` every tick. Position +// reapply/strip logic must therefore stand down for all of them, not just x/y. +const GSAP_TRANSFORM_PROPS = [ + "x", + "y", + "xPercent", + "yPercent", + "scale", + "scaleX", + "scaleY", + "rotation", + "rotate", + "rotationX", + "rotationY", + "skewX", + "skewY", + "transform", +]; + +/** + * True when GSAP animates any transform-affecting property on the element, + * meaning GSAP owns `style.transform` and has neutralized CSS `translate`. + */ +export function gsapAnimatesTransform(el: HTMLElement): boolean { + return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS); +} + /** * 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 & { diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b14f03ef0..30600d934 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -47,12 +47,6 @@ export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag true, ); -export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( - env, - ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT"], - false, -); - export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag( env, [STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"], @@ -89,6 +83,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag( false, ); +// When disabled (the default), drag/resize/rotate commits always take the CSS +// persist path instead of being intercepted into GSAP script keyframe +// mutations. The keyframe intercept rewrites timeline tweens from drag +// gestures and is opt-in until its recording path is hardened. +export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"], + false, +); + export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index fbf8fdc60..647b125e0 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,7 +32,7 @@ import { } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; -import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -223,6 +223,7 @@ 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"); @@ -257,6 +258,18 @@ 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( @@ -268,6 +281,24 @@ 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) => 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 }, @@ -275,11 +306,11 @@ export function applyStudioPathOffsetDraft( promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); + const isGsapAnimated = gsapAnimatesTransform(element); if (isGsapAnimated) { - // For GSAP-animated elements: use gsap.set for positioning (the timeline - // is paused during drag). Set translate:none explicitly to prevent - // double-counting with the transform. + // 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) => void } }) @@ -520,10 +551,14 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - const gsapSkip = gsapAnimatesProperty(el, "x", "y"); + // 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 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, diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 428315829..3b5bf5613 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { describe, it, expect } from "vitest"; diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.ts b/packages/studio/src/components/editor/manualEditsDomPatches.ts index 532920038..83dea1bb4 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.ts @@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]): export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { const ops: PatchOperation[] = []; - collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops); + collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops); + // When GSAP owns the element's transform, the live inline translate is kept + // at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset). + // Persist the var() expression in that case, so a reload re-folds the offset. + const inlineTranslate = element.style.getPropertyValue("translate"); + const hasOffsetVars = + element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) || + element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + const translateValue = + inlineTranslate && inlineTranslate !== "none" + ? inlineTranslate + : hasOffsetVars + ? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)` + : null; + if (translateValue) { + ops.push({ type: "inline-style", property: "translate", value: translateValue }); + } ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" }); collectAttributeOps( element, diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 1cf840ff5..df9569ab4 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -4,6 +4,7 @@ import { styleUsesStudioRotation, restoreInlineDisplay, } from "./manualEditsDom"; +import { gsapAnimatesTransform } from "./gsapAnimatesProperty"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, @@ -87,7 +88,23 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps }; } +type GsapWindow = Window & { + gsap?: { + getProperty?: (el: Element, prop: string) => number | string; + set?: (el: Element, vars: Record) => 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), @@ -95,6 +112,8 @@ 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, }; } @@ -183,6 +202,13 @@ export function restoreStudioPathOffset( STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, 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 }); + } } /* ── Clear functions ──────────────────────────────────────────────── */ diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index f54071182..6ef53aaa1 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -101,4 +101,12 @@ 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; } diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c62fda6b4..3c5e3f3cf 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { useDomEditSession } from "../hooks/useDomEditSession"; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 752b0fff2..8c59b69e5 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from "react"; import { usePlayerStore } from "../player"; +import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; import { FONT_EXT } from "../utils/mediaTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -41,10 +42,13 @@ import type { EditHistoryKind } from "../utils/editHistory"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; // ── Helpers ── - type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; +// fallow-ignore-next-line complexity function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + // When the GSAP drag intercept is disabled for debugging, treat every + // element as un-targeted so commits take the plain CSS persist path. + if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; if (!iframe?.contentWindow) return false; let timelines: Record | undefined; try { @@ -168,6 +172,7 @@ export function useDomEditCommits({ // fallow-ignore-next-line complexity const persistDomEditOperations: PersistDomEditOperations = useCallback( + // fallow-ignore-next-line complexity async (selection, operations, options) => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -455,6 +460,7 @@ export function useDomEditCommits({ // fallow-ignore-next-line complexity const handleDomEditElementDelete = useCallback( + // fallow-ignore-next-line complexity async (selection: DomEditSelection) => { const pid = projectIdRef.current; if (!pid) return; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 242e2ced1..a191452d9 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { - STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, STUDIO_GSAP_PANEL_ENABLED, + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; @@ -329,7 +329,7 @@ export function useDomEditSession({ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapDragIntercept( selection, next, @@ -375,7 +375,7 @@ export function useDomEditSession({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapResizeIntercept( selection, next, @@ -399,7 +399,7 @@ export function useDomEditSession({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { const handled = await tryGsapRotationIntercept( selection, next.angle,