mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Fable 5
parent
511665b93a
commit
fc3ab76ce8
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user