mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe (#1498)
* feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(sdk,studio): ws-1.3 — removeGsapProperty SDK op + Studio hook cutover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(sdk,studio): ws-1.4 — deleteAllForSelector SDK op + Studio hook cutover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core): cascade-remove GSAP tweens in removeElementFromHtml (WS-2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
Claude Sonnet 4.6
parent
a5016ed416
commit
ceb815c318
@@ -878,6 +878,27 @@ export function removeKeyframeFromScript(
|
|||||||
return ms.toString();
|
return ms.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removePropertyFromAnimation(
|
||||||
|
script: string,
|
||||||
|
animationId: string,
|
||||||
|
property: string,
|
||||||
|
from = false,
|
||||||
|
): string {
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsed) return script;
|
||||||
|
const target = parsed.located.find((l) => l.id === animationId);
|
||||||
|
if (!target) return script;
|
||||||
|
const { call } = target;
|
||||||
|
const objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg;
|
||||||
|
if (!objNode) return script;
|
||||||
|
const propNode = findPropertyNode(objNode, property);
|
||||||
|
if (!propNode) return script;
|
||||||
|
const allProps = (objNode.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||||
|
const ms = new MagicString(script);
|
||||||
|
removeProp(ms, propNode, allProps);
|
||||||
|
return ms.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Label write ops ───────────────────────────────────────────────────────────
|
// ── Label write ops ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function addLabelToScript(script: string, name: string, position: number): string {
|
export function addLabelToScript(script: string, name: string, position: number): string {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
} from "../core.types";
|
} from "../core.types";
|
||||||
import { validateCompositionGsap } from "./gsapSerialize";
|
import { validateCompositionGsap } from "./gsapSerialize";
|
||||||
import { ensureHfIds } from "./hfIds.js";
|
import { ensureHfIds } from "./hfIds.js";
|
||||||
|
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
|
||||||
|
import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
|
||||||
import type { ValidationResult } from "../core.types";
|
import type { ValidationResult } from "../core.types";
|
||||||
|
|
||||||
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
|
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
|
||||||
@@ -672,15 +674,40 @@ export function addElementToHtml(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectorTargetsId(selector: string, id: string): boolean {
|
||||||
|
return (
|
||||||
|
selector === `#${id}` ||
|
||||||
|
selector === `[data-hf-id="${id}"]` ||
|
||||||
|
selector === `[data-hf-id='${id}']`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGsapForId(script: string, elementId: string): string {
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsed) return script;
|
||||||
|
let current = script;
|
||||||
|
for (const { id: animId, animation } of parsed.located) {
|
||||||
|
if (selectorTargetsId(animation.targetSelector, elementId)) {
|
||||||
|
current = removeAnimationFromScript(current, animId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cascadeRemoveGsapById(doc: Document, elementId: string): void {
|
||||||
|
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||||
|
const text = script.textContent ?? "";
|
||||||
|
if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue;
|
||||||
|
const updated = stripGsapForId(text, elementId);
|
||||||
|
if (updated !== text) script.textContent = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function removeElementFromHtml(html: string, elementId: string): string {
|
export function removeElementFromHtml(html: string, elementId: string): string {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, "text/html");
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
doc.getElementById(elementId)?.remove();
|
||||||
const el = doc.getElementById(elementId);
|
cascadeRemoveGsapById(doc, elementId);
|
||||||
if (el) {
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
addAnimationToScript,
|
addAnimationToScript,
|
||||||
updateAnimationInScript,
|
updateAnimationInScript,
|
||||||
removeAnimationFromScript,
|
removeAnimationFromScript,
|
||||||
|
removePropertyFromAnimation,
|
||||||
addKeyframeToScript,
|
addKeyframeToScript,
|
||||||
removeKeyframeFromScript,
|
removeKeyframeFromScript,
|
||||||
updateKeyframeInScript,
|
updateKeyframeInScript,
|
||||||
@@ -136,7 +137,48 @@ function targets(target: HfId | HfId[]): HfId[] {
|
|||||||
|
|
||||||
// ─── Op dispatch ────────────────────────────────────────────────────────────
|
// ─── Op dispatch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function dispatchRemoveGsapKeyframe(
|
||||||
|
parsed: ParsedDocument,
|
||||||
|
op: Extract<EditOp, { type: "removeGsapKeyframe" }>,
|
||||||
|
): MutationResult {
|
||||||
|
return "percentage" in op
|
||||||
|
? handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage)
|
||||||
|
: handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||||
|
switch (op.type) {
|
||||||
|
case "addGsapTween":
|
||||||
|
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||||
|
case "setGsapTween":
|
||||||
|
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||||
|
case "removeGsapProperty":
|
||||||
|
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
|
||||||
|
case "removeGsapTween":
|
||||||
|
return handleRemoveGsapTween(parsed, op.animationId);
|
||||||
|
case "deleteAllForSelector":
|
||||||
|
return handleDeleteAllForSelector(parsed, op.selector);
|
||||||
|
case "setGsapKeyframe":
|
||||||
|
return handleSetGsapKeyframe(
|
||||||
|
parsed,
|
||||||
|
op.animationId,
|
||||||
|
op.keyframeIndex,
|
||||||
|
op.position,
|
||||||
|
op.value,
|
||||||
|
op.ease,
|
||||||
|
);
|
||||||
|
case "addGsapKeyframe":
|
||||||
|
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
|
||||||
|
case "removeGsapKeyframe":
|
||||||
|
return dispatchRemoveGsapKeyframe(parsed, op);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||||
|
const gsap = applyGsapOp(parsed, op);
|
||||||
|
if (gsap !== undefined) return gsap;
|
||||||
switch (op.type) {
|
switch (op.type) {
|
||||||
case "setStyle":
|
case "setStyle":
|
||||||
return handleSetStyle(parsed, targets(op.target), op.styles);
|
return handleSetStyle(parsed, targets(op.target), op.styles);
|
||||||
@@ -160,31 +202,14 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
|||||||
return handleSetCompositionMetadata(parsed, op);
|
return handleSetCompositionMetadata(parsed, op);
|
||||||
case "setVariableValue":
|
case "setVariableValue":
|
||||||
return handleSetVariableValue(parsed, op.id, op.value);
|
return handleSetVariableValue(parsed, op.id, op.value);
|
||||||
case "addGsapTween":
|
case "setClassStyle":
|
||||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
return handleSetClassStyle(parsed, op.selector, op.styles);
|
||||||
case "setGsapTween":
|
|
||||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
|
||||||
case "removeGsapTween":
|
|
||||||
return handleRemoveGsapTween(parsed, op.animationId);
|
|
||||||
case "setGsapKeyframe":
|
|
||||||
return handleSetGsapKeyframe(
|
|
||||||
parsed,
|
|
||||||
op.animationId,
|
|
||||||
op.keyframeIndex,
|
|
||||||
op.position,
|
|
||||||
op.value,
|
|
||||||
op.ease,
|
|
||||||
);
|
|
||||||
case "addGsapKeyframe":
|
|
||||||
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
|
|
||||||
case "removeGsapKeyframe":
|
|
||||||
return handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
|
|
||||||
case "addLabel":
|
case "addLabel":
|
||||||
return handleAddLabel(parsed, op.name, op.position);
|
return handleAddLabel(parsed, op.name, op.position);
|
||||||
case "removeLabel":
|
case "removeLabel":
|
||||||
return handleRemoveLabel(parsed, op.name);
|
return handleRemoveLabel(parsed, op.name);
|
||||||
case "setClassStyle":
|
default:
|
||||||
return handleSetClassStyle(parsed, op.selector, op.styles);
|
throw new UnsupportedOpError((op as EditOp).type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,6 +714,20 @@ function handleSetGsapTween(
|
|||||||
return gsapScriptChange(script, newScript);
|
return gsapScriptChange(script, newScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRemoveGsapProperty(
|
||||||
|
parsed: ParsedDocument,
|
||||||
|
animationId: string,
|
||||||
|
property: string,
|
||||||
|
from: boolean | undefined,
|
||||||
|
): MutationResult {
|
||||||
|
const script = getGsapScript(parsed.document);
|
||||||
|
if (!script) return EMPTY;
|
||||||
|
const newScript = removePropertyFromAnimation(script, animationId, property, from ?? false);
|
||||||
|
if (newScript === script) return EMPTY;
|
||||||
|
setGsapScript(parsed.document, newScript);
|
||||||
|
return gsapScriptChange(script, newScript);
|
||||||
|
}
|
||||||
|
|
||||||
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
|
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
|
||||||
const script = getGsapScript(parsed.document);
|
const script = getGsapScript(parsed.document);
|
||||||
if (!script) return EMPTY;
|
if (!script) return EMPTY;
|
||||||
@@ -698,6 +737,24 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut
|
|||||||
return gsapScriptChange(script, newScript);
|
return gsapScriptChange(script, newScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
|
||||||
|
const script = getGsapScript(parsed.document);
|
||||||
|
if (!script) return EMPTY;
|
||||||
|
const parsedForWrite = parseGsapScriptAcornForWrite(script);
|
||||||
|
if (!parsedForWrite) return EMPTY;
|
||||||
|
const matching = parsedForWrite.located.filter((l) => l.animation.targetSelector === selector);
|
||||||
|
if (matching.length === 0) return EMPTY;
|
||||||
|
let newScript = script;
|
||||||
|
for (const m of [...matching].reverse()) {
|
||||||
|
newScript = removeAnimationFromScript(newScript, m.id);
|
||||||
|
}
|
||||||
|
if (newScript === script) return EMPTY;
|
||||||
|
setGsapScript(parsed.document, newScript);
|
||||||
|
// ponytail: skips stripStudioEditsFromTarget (data-hf-studio-path-offset cleanup) —
|
||||||
|
// studio path offset is cosmetic once all animations are gone; session reloads after write
|
||||||
|
return gsapScriptChange(script, newScript);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveKeyframe(parsed: ParsedDocument, animationId: string, keyframeIndex: number) {
|
function resolveKeyframe(parsed: ParsedDocument, animationId: string, keyframeIndex: number) {
|
||||||
const script = getGsapScript(parsed.document);
|
const script = getGsapScript(parsed.document);
|
||||||
if (!script) return null;
|
if (!script) return null;
|
||||||
@@ -772,6 +829,28 @@ function handleAddGsapKeyframe(
|
|||||||
return gsapScriptChange(script, newScript);
|
return gsapScriptChange(script, newScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRemoveGsapKeyframeByPercentage(
|
||||||
|
parsed: ParsedDocument,
|
||||||
|
animationId: string,
|
||||||
|
percentage: number,
|
||||||
|
): MutationResult {
|
||||||
|
const script = getGsapScript(parsed.document);
|
||||||
|
if (!script) return EMPTY;
|
||||||
|
const parsedForWrite = parseGsapScriptAcornForWrite(script);
|
||||||
|
const located = parsedForWrite?.located.find((l) => l.id === animationId);
|
||||||
|
const kfs = located?.animation.keyframes?.keyframes;
|
||||||
|
if (!kfs) return EMPTY;
|
||||||
|
// No-op on ambiguity: duplicate-percentage keyframes can't be disambiguated.
|
||||||
|
const TOLERANCE = 0.001;
|
||||||
|
const matches = kfs.filter((k) => Math.abs(k.percentage - percentage) <= TOLERANCE);
|
||||||
|
if (matches.length !== 1) return EMPTY;
|
||||||
|
const pct = matches[0]!.percentage;
|
||||||
|
const newScript = removeKeyframeFromScript(script, animationId, pct);
|
||||||
|
if (newScript === script) return EMPTY;
|
||||||
|
setGsapScript(parsed.document, newScript);
|
||||||
|
return gsapScriptChange(script, newScript);
|
||||||
|
}
|
||||||
|
|
||||||
function handleRemoveGsapKeyframe(
|
function handleRemoveGsapKeyframe(
|
||||||
parsed: ParsedDocument,
|
parsed: ParsedDocument,
|
||||||
animationId: string,
|
animationId: string,
|
||||||
@@ -873,7 +952,9 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
|||||||
case "setGsapKeyframe":
|
case "setGsapKeyframe":
|
||||||
case "addGsapKeyframe":
|
case "addGsapKeyframe":
|
||||||
case "removeGsapKeyframe":
|
case "removeGsapKeyframe":
|
||||||
|
case "removeGsapProperty":
|
||||||
case "removeGsapTween":
|
case "removeGsapTween":
|
||||||
|
case "deleteAllForSelector":
|
||||||
case "removeLabel":
|
case "removeLabel":
|
||||||
if (getGsapScript(parsed.document) === null)
|
if (getGsapScript(parsed.document) === null)
|
||||||
return canErr(
|
return canErr(
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ export type EditOp =
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
| { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number }
|
| { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number }
|
||||||
|
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
|
||||||
|
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
|
||||||
| { type: "removeGsapTween"; animationId: string }
|
| { type: "removeGsapTween"; animationId: string }
|
||||||
|
| { type: "deleteAllForSelector"; selector: string }
|
||||||
| { type: "addLabel"; name: string; position: number }
|
| { type: "addLabel"; name: string; position: number }
|
||||||
| { type: "removeLabel"; name: string };
|
| { type: "removeLabel"; name: string };
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|||||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||||
|
|
||||||
// Re-export so existing consumers keep their import path
|
|
||||||
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||||
@@ -45,8 +42,6 @@ interface RecordEditInput {
|
|||||||
files: Record<string, { before: string; after: string }>;
|
files: Record<string, { before: string; after: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
||||||
|
|
||||||
export interface UseDomEditCommitsParams {
|
export interface UseDomEditCommitsParams {
|
||||||
activeCompPath: string | null;
|
activeCompPath: string | null;
|
||||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useCallback } from "react";
|
|||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { roundTo3 } from "../utils/rounding";
|
import { roundTo3 } from "../utils/rounding";
|
||||||
import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover";
|
import {
|
||||||
|
sdkGsapTweenPersist,
|
||||||
|
sdkGsapDeleteAllForSelectorPersist,
|
||||||
|
type CutoverDeps,
|
||||||
|
} from "../utils/sdkCutover";
|
||||||
import {
|
import {
|
||||||
assignGsapTargetAutoIdIfNeeded,
|
assignGsapTargetAutoIdIfNeeded,
|
||||||
ensureElementAddressable,
|
ensureElementAddressable,
|
||||||
@@ -80,15 +84,25 @@ export function useGsapAnimationOps({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllForSelector = useCallback(
|
const deleteAllForSelector = useCallback(
|
||||||
(selection: DomEditSelection, targetSelector: string) => {
|
async (selection: DomEditSelection, targetSelector: string) => {
|
||||||
// ponytail: no SDK op for delete-all-for-selector; stays server-authoritative
|
if (sdkSession && sdkDeps) {
|
||||||
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||||
|
const handled = await sdkGsapDeleteAllForSelectorPersist(
|
||||||
|
targetPath,
|
||||||
|
targetSelector,
|
||||||
|
sdkSession,
|
||||||
|
sdkDeps,
|
||||||
|
{ label: "Delete all animations for element" },
|
||||||
|
);
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
void commitMutation(
|
void commitMutation(
|
||||||
selection,
|
selection,
|
||||||
{ type: "delete-all-for-selector", targetSelector },
|
{ type: "delete-all-for-selector", targetSelector },
|
||||||
{ label: "Delete all animations for element" },
|
{ label: "Delete all animations for element" },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[commitMutation],
|
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
||||||
);
|
);
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { executeOptimistic } from "../utils/optimisticUpdate";
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||||
import { sdkGsapKeyframePersist, type CutoverDeps } from "../utils/sdkCutover";
|
import {
|
||||||
|
sdkGsapKeyframePersist,
|
||||||
|
sdkGsapRemoveKeyframePersist,
|
||||||
|
type CutoverDeps,
|
||||||
|
} from "../utils/sdkCutover";
|
||||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||||
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
||||||
@@ -151,9 +155,6 @@ export function useGsapKeyframeOps({
|
|||||||
|
|
||||||
const removeKeyframe = useCallback(
|
const removeKeyframe = useCallback(
|
||||||
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
||||||
// ponytail: SDK removeGsapKeyframe uses keyframeIndex (not percentage); mismatch with
|
|
||||||
// Studio's percentage-based API. Resolving index requires parsing GSAP state at call
|
|
||||||
// time — deferred. removeKeyframe stays server-authoritative.
|
|
||||||
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
||||||
const mutation = { type: "remove-keyframe", animationId, percentage };
|
const mutation = { type: "remove-keyframe", animationId, percentage };
|
||||||
void executeOptimisticKeyframeCacheUpdate({
|
void executeOptimisticKeyframeCacheUpdate({
|
||||||
@@ -162,19 +163,31 @@ export function useGsapKeyframeOps({
|
|||||||
apply: (prev) => ({
|
apply: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
keyframes: prev.keyframes.filter(
|
keyframes: prev.keyframes.filter(
|
||||||
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
|
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.001,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
persist: () =>
|
persist: async () => {
|
||||||
commitMutation(selection, mutation, {
|
if (sdkSession && sdkDeps) {
|
||||||
|
const handled = await sdkGsapRemoveKeyframePersist(
|
||||||
|
sourceFile,
|
||||||
|
animationId,
|
||||||
|
percentage,
|
||||||
|
sdkSession,
|
||||||
|
sdkDeps,
|
||||||
|
{ label: `Remove keyframe at ${percentage}%` },
|
||||||
|
);
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
await commitMutation(selection, mutation, {
|
||||||
label: `Remove keyframe at ${percentage}%`,
|
label: `Remove keyframe at ${percentage}%`,
|
||||||
softReload: true,
|
softReload: true,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[activeCompPath, commitMutation, trackGsapSaveFailure],
|
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const convertToKeyframes = useCallback(
|
const convertToKeyframes = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover";
|
import {
|
||||||
|
sdkGsapTweenPersist,
|
||||||
|
sdkGsapRemovePropertyPersist,
|
||||||
|
type CutoverDeps,
|
||||||
|
} from "../utils/sdkCutover";
|
||||||
import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
|
import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
|
||||||
import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
||||||
|
|
||||||
@@ -110,16 +114,47 @@ export function useGsapPropertyDebounce(
|
|||||||
[commitMutationSafely, sdk],
|
[commitMutationSafely, sdk],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeGsapProperty = useCallback(
|
const removeProperty = useCallback(
|
||||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => {
|
||||||
// ponytail: null ≠ removal in upsertProp; remove-property stays server-authoritative
|
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
|
||||||
commitMutationSafely(
|
if (sdkSession && sdkDeps) {
|
||||||
selection,
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||||
{ type: "remove-property", animationId, property },
|
const handled = await sdkGsapRemovePropertyPersist(
|
||||||
{ label: `Remove GSAP ${property}` },
|
targetPath,
|
||||||
);
|
animationId,
|
||||||
|
property,
|
||||||
|
from,
|
||||||
|
sdkSession,
|
||||||
|
sdkDeps,
|
||||||
|
{ label: `Remove GSAP ${from ? `from-${property}` : property}` },
|
||||||
|
);
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
if (from) {
|
||||||
|
commitMutationSafely(
|
||||||
|
selection,
|
||||||
|
{ type: "remove-from-property", animationId, property },
|
||||||
|
{
|
||||||
|
label: `Remove GSAP from-${property}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
commitMutationSafely(
|
||||||
|
selection,
|
||||||
|
{ type: "remove-property", animationId, property },
|
||||||
|
{
|
||||||
|
label: `Remove GSAP ${property}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[commitMutationSafely],
|
[commitMutationSafely, sdk],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeGsapProperty = useCallback(
|
||||||
|
(selection: DomEditSelection, animationId: string, property: string) =>
|
||||||
|
removeProperty(selection, animationId, property, false),
|
||||||
|
[removeProperty],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateGsapFromProperty = useCallback(
|
const updateGsapFromProperty = useCallback(
|
||||||
@@ -185,15 +220,9 @@ export function useGsapPropertyDebounce(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeGsapFromProperty = useCallback(
|
const removeGsapFromProperty = useCallback(
|
||||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
(selection: DomEditSelection, animationId: string, property: string) =>
|
||||||
// ponytail: null ≠ removal in upsertProp; remove-from-property stays server-authoritative
|
removeProperty(selection, animationId, property, true),
|
||||||
commitMutationSafely(
|
[removeProperty],
|
||||||
selection,
|
|
||||||
{ type: "remove-from-property", animationId, property },
|
|
||||||
{ label: `Remove GSAP from-${property}` },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[commitMutationSafely],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -192,32 +192,44 @@ type SdkGsapTweenOp =
|
|||||||
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||||
| { kind: "remove"; animationId: string };
|
| { kind: "remove"; animationId: string };
|
||||||
|
|
||||||
export async function sdkGsapTweenPersist(
|
export function sdkGsapTweenPersist(
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
op: SdkGsapTweenOp,
|
op: SdkGsapTweenOp,
|
||||||
sdkSession: Composition | null | undefined,
|
sdkSession: Composition | null | undefined,
|
||||||
deps: CutoverDeps,
|
deps: CutoverDeps,
|
||||||
options?: CutoverOptions,
|
options?: CutoverOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
|
||||||
|
return Promise.resolve(false);
|
||||||
|
// dispatchGsapOpAndPersist returns false on before===after — that catches stale
|
||||||
|
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
|
||||||
|
// back to the server path. This subsumes explicit existence guards for set/remove.
|
||||||
|
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
|
||||||
|
s.batch(() => {
|
||||||
|
if (op.kind === "add") {
|
||||||
|
s.addGsapTween(op.target, op.spec);
|
||||||
|
} else if (op.kind === "set") {
|
||||||
|
s.setGsapTween(op.animationId, op.properties);
|
||||||
|
} else {
|
||||||
|
s.removeGsapTween(op.animationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchGsapOpAndPersist(
|
||||||
|
targetPath: string,
|
||||||
|
sdkSession: Composition | null | undefined,
|
||||||
|
deps: CutoverDeps,
|
||||||
|
options: CutoverOptions | undefined,
|
||||||
|
dispatch: (s: Composition) => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!sdkSession) return false;
|
if (!sdkSession) return false;
|
||||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||||
try {
|
try {
|
||||||
if (op.kind === "add" && !sdkSession.getElement(op.target)) return false;
|
|
||||||
const before = sdkSession.serialize();
|
const before = sdkSession.serialize();
|
||||||
sdkSession.batch(() => {
|
dispatch(sdkSession);
|
||||||
if (op.kind === "add") {
|
|
||||||
sdkSession.addGsapTween(op.target, op.spec);
|
|
||||||
} else if (op.kind === "set") {
|
|
||||||
sdkSession.setGsapTween(op.animationId, op.properties);
|
|
||||||
} else {
|
|
||||||
sdkSession.removeGsapTween(op.animationId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const after = sdkSession.serialize();
|
const after = sdkSession.serialize();
|
||||||
// No-op (stale animationId, unsupported shape e.g. from-prop on a plain
|
|
||||||
// tween): fall back to the server path so it surfaces the proper error
|
|
||||||
// instead of writing a phantom before==after undo step. Subsumes a
|
|
||||||
// per-op existence guard for the set/remove branches.
|
|
||||||
if (after === before) return false;
|
if (after === before) return false;
|
||||||
await persistSdkSerialize(after, targetPath, before, deps, options);
|
await persistSdkSerialize(after, targetPath, before, deps, options);
|
||||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
||||||
@@ -228,7 +240,7 @@ export async function sdkGsapTweenPersist(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sdkGsapKeyframePersist(
|
export function sdkGsapKeyframePersist(
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
animationId: string,
|
animationId: string,
|
||||||
position: number,
|
position: number,
|
||||||
@@ -237,22 +249,48 @@ export async function sdkGsapKeyframePersist(
|
|||||||
deps: CutoverDeps,
|
deps: CutoverDeps,
|
||||||
options?: CutoverOptions,
|
options?: CutoverOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!sdkSession) return false;
|
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
|
||||||
try {
|
);
|
||||||
const before = sdkSession.serialize();
|
}
|
||||||
sdkSession.batch(() =>
|
|
||||||
sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }),
|
export function sdkGsapRemoveKeyframePersist(
|
||||||
);
|
targetPath: string,
|
||||||
const after = sdkSession.serialize();
|
animationId: string,
|
||||||
if (after === before) return false;
|
percentage: number,
|
||||||
await persistSdkSerialize(after, targetPath, before, deps, options);
|
sdkSession: Composition | null | undefined,
|
||||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
deps: CutoverDeps,
|
||||||
return true;
|
options?: CutoverOptions,
|
||||||
} catch (err) {
|
): Promise<boolean> {
|
||||||
trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
|
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||||
return false;
|
s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sdkGsapRemovePropertyPersist(
|
||||||
|
targetPath: string,
|
||||||
|
animationId: string,
|
||||||
|
property: string,
|
||||||
|
from: boolean,
|
||||||
|
sdkSession: Composition | null | undefined,
|
||||||
|
deps: CutoverDeps,
|
||||||
|
options?: CutoverOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||||
|
s.dispatch({ type: "removeGsapProperty", animationId, property, from }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sdkGsapDeleteAllForSelectorPersist(
|
||||||
|
targetPath: string,
|
||||||
|
selector: string,
|
||||||
|
sdkSession: Composition | null | undefined,
|
||||||
|
deps: CutoverDeps,
|
||||||
|
options?: CutoverOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||||
|
s.dispatch({ type: "deleteAllForSelector", selector }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sdkDeletePersist(
|
export async function sdkDeletePersist(
|
||||||
|
|||||||
Reference in New Issue
Block a user