fix(studio,core): persist manual position edits for GSAP-owned elements (#1346)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore: update bun.lock after sdk package registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-11 12:30:14 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 511665b93a
commit fc3ab76ce8
12 changed files with 182 additions and 23 deletions
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
export interface StudioManualEditsRenderScriptOptions { export interface StudioManualEditsRenderScriptOptions {
activeCompositionPath?: string | null; activeCompositionPath?: string | null;
} }
@@ -223,6 +223,33 @@ function isSafeAttributeValue(name: string, value: string): boolean {
return true; return true;
} }
function patchStyleAttrString(style: string, property: string, value: string | null): string {
const props = new Map<string, string>();
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( export function patchElementInHtml(
source: string, source: string,
target: SourceMutationTarget, target: SourceMutationTarget,
@@ -236,10 +263,14 @@ export function patchElementInHtml(
for (const op of operations) { for (const op of operations) {
switch (op.type) { switch (op.type) {
case "inline-style": case "inline-style":
if (op.value != null) { // linkedom's CSSStyleDeclaration does not support CSS custom properties
htmlEl.style.setProperty(op.property, op.value); // (--foo) or newer individual transform properties (translate, rotate,
} else { // scale) via style.setProperty(). Manipulate the style attribute string
htmlEl.style.removeProperty(op.property); // 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; break;
case "attribute": case "attribute":
@@ -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 * Checks whether GSAP actively animates one or more CSS/GSAP properties on
* the given element by inspecting all registered `__timelines`. * the given element by inspecting all registered `__timelines`.
*/ */
// fallow-ignore-next-line complexity
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
const win = el.ownerDocument.defaultView as const win = el.ownerDocument.defaultView as
| (Window & { | (Window & {
@@ -47,12 +47,6 @@ export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag
true, true,
); );
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT"],
false,
);
export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
env, env,
[STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"], [STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
@@ -89,6 +83,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
false, 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_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -32,7 +32,7 @@ import {
} from "./manualEditsTypes"; } from "./manualEditsTypes";
import { roundRotationAngle } from "./manualEditsParsing"; import { roundRotationAngle } from "./manualEditsParsing";
import { applyStudioMotionFromDom } from "./studioMotion"; import { applyStudioMotionFromDom } from "./studioMotion";
import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty";
/* ── Gesture tracking ─────────────────────────────────────────────── */ /* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0; 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; 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 { function stripGsapTranslateFromTransform(element: HTMLElement): void {
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
const transform = element.style.getPropertyValue("transform"); const transform = element.style.getPropertyValue("transform");
@@ -257,6 +258,18 @@ export function applyStudioPathOffset(
): void { ): void {
promoteInlineForTransform(element); promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); 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( element.style.setProperty(
"translate", "translate",
composeTranslateValue( composeTranslateValue(
@@ -268,6 +281,24 @@ export function applyStudioPathOffset(
stripGsapTranslateFromTransform(element); 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( export function applyStudioPathOffsetDraft(
element: HTMLElement, element: HTMLElement,
offset: { x: number; y: number }, offset: { x: number; y: number },
@@ -275,11 +306,11 @@ export function applyStudioPathOffsetDraft(
promoteInlineForTransform(element); promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false }); writeStudioPathOffsetVars(element, offset, { updateBase: false });
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); const isGsapAnimated = gsapAnimatesTransform(element);
if (isGsapAnimated) { if (isGsapAnimated) {
// For GSAP-animated elements: use gsap.set for positioning (the timeline // GSAP owns style.transform (see applyStudioPathOffset): position via
// is paused during drag). Set translate:none explicitly to prevent // gsap.set while the timeline is paused. Set translate:none explicitly to
// double-counting with the transform. // prevent double-counting with the baked transform.
element.style.setProperty("translate", "none"); element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as 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 } })
@@ -520,10 +551,14 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
function reapplyPathOffsets(doc: Document): void { function reapplyPathOffsets(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { 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 x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (gsapSkip) continue;
if (x || y) { if (x || y) {
applyStudioPathOffset( applyStudioPathOffset(
el, el,
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
const ops: 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" }); ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
collectAttributeOps( collectAttributeOps(
element, element,
@@ -4,6 +4,7 @@ import {
styleUsesStudioRotation, styleUsesStudioRotation,
restoreInlineDisplay, restoreInlineDisplay,
} from "./manualEditsDom"; } from "./manualEditsDom";
import { gsapAnimatesTransform } from "./gsapAnimatesProperty";
import { import {
STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_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<string, unknown>) => void;
};
};
export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot { 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 { return {
translate: element.style.getPropertyValue("translate"), translate: element.style.getPropertyValue("translate"),
x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP), 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), marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR),
originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR), originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR),
originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR), originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR),
gsapX,
gsapY,
}; };
} }
@@ -183,6 +202,13 @@ export function restoreStudioPathOffset(
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
previous.originalInlineTranslate, 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 ──────────────────────────────────────────────── */ /* ── Clear functions ──────────────────────────────────────────────── */
@@ -101,4 +101,12 @@ export interface StudioPathOffsetSnapshot {
marker: string | null; marker: string | null;
originalTranslate: string | null; originalTranslate: string | null;
originalInlineTranslate: 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;
} }
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { useDomEditSession } from "../hooks/useDomEditSession"; import type { useDomEditSession } from "../hooks/useDomEditSession";
@@ -1,5 +1,6 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { usePlayerStore } from "../player"; import { usePlayerStore } from "../player";
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
import { FONT_EXT } from "../utils/mediaTypes"; import { FONT_EXT } from "../utils/mediaTypes";
import type { PatchOperation } from "../utils/sourcePatcher"; import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry"; import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -41,10 +42,13 @@ import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Helpers ── // ── Helpers ──
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
// fallow-ignore-next-line complexity
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { 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; if (!iframe?.contentWindow) return false;
let timelines: Record<string, TimelineLike> | undefined; let timelines: Record<string, TimelineLike> | undefined;
try { try {
@@ -168,6 +172,7 @@ export function useDomEditCommits({
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
const persistDomEditOperations: PersistDomEditOperations = useCallback( const persistDomEditOperations: PersistDomEditOperations = useCallback(
// fallow-ignore-next-line complexity
async (selection, operations, options) => { async (selection, operations, options) => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
if (!pid) throw new Error("No active project"); if (!pid) throw new Error("No active project");
@@ -455,6 +460,7 @@ export function useDomEditCommits({
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
const handleDomEditElementDelete = useCallback( const handleDomEditElementDelete = useCallback(
// fallow-ignore-next-line complexity
async (selection: DomEditSelection) => { async (selection: DomEditSelection) => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
if (!pid) return; if (!pid) return;
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react";
import type { TimelineElement } from "../player"; import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player"; import { usePlayerStore } from "../player";
import { import {
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
STUDIO_GSAP_PANEL_ENABLED, STUDIO_GSAP_PANEL_ENABLED,
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
} from "../components/editor/manualEditingAvailability"; } from "../components/editor/manualEditingAvailability";
import { type DomEditSelection } from "../components/editor/domEditing"; import { type DomEditSelection } from "../components/editor/domEditing";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
@@ -329,7 +329,7 @@ export function useDomEditSession({
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
const handleGsapAwarePathOffsetCommit = useCallback( const handleGsapAwarePathOffsetCommit = useCallback(
async (selection: DomEditSelection, next: { x: number; y: number }) => { 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( const handled = await tryGsapDragIntercept(
selection, selection,
next, next,
@@ -375,7 +375,7 @@ export function useDomEditSession({
const handleGsapAwareBoxSizeCommit = useCallback( const handleGsapAwareBoxSizeCommit = useCallback(
async (selection: DomEditSelection, next: { width: number; height: number }) => { 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( const handled = await tryGsapResizeIntercept(
selection, selection,
next, next,
@@ -399,7 +399,7 @@ export function useDomEditSession({
const handleGsapAwareRotationCommit = useCallback( const handleGsapAwareRotationCommit = useCallback(
async (selection: DomEditSelection, next: { angle: number }) => { async (selection: DomEditSelection, next: { angle: number }) => {
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
const handled = await tryGsapRotationIntercept( const handled = await tryGsapRotationIntercept(
selection, selection,
next.angle, next.angle,