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:
Vance Ingalls
2026-06-17 16:49:47 -07:00
committed by GitHub
co-authored by Miguel Ángel Claude Sonnet 4.6
parent a5016ed416
commit ceb815c318
9 changed files with 317 additions and 96 deletions
@@ -878,6 +878,27 @@ export function removeKeyframeFromScript(
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 ───────────────────────────────────────────────────────────
export function addLabelToScript(script: string, name: string, position: number): string {
+33 -6
View File
@@ -12,6 +12,8 @@ import type {
} from "../core.types";
import { validateCompositionGsap } from "./gsapSerialize";
import { ensureHfIds } from "./hfIds.js";
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
import type { ValidationResult } from "../core.types";
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 {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const el = doc.getElementById(elementId);
if (el) {
el.remove();
}
doc.getElementById(elementId)?.remove();
cascadeRemoveGsapById(doc, elementId);
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
}
+102 -21
View File
@@ -47,6 +47,7 @@ import {
addAnimationToScript,
updateAnimationInScript,
removeAnimationFromScript,
removePropertyFromAnimation,
addKeyframeToScript,
removeKeyframeFromScript,
updateKeyframeInScript,
@@ -136,7 +137,48 @@ function targets(target: HfId | HfId[]): HfId[] {
// ─── 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 {
const gsap = applyGsapOp(parsed, op);
if (gsap !== undefined) return gsap;
switch (op.type) {
case "setStyle":
return handleSetStyle(parsed, targets(op.target), op.styles);
@@ -160,31 +202,14 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleSetCompositionMetadata(parsed, op);
case "setVariableValue":
return handleSetVariableValue(parsed, op.id, op.value);
case "addGsapTween":
return handleAddGsapTween(parsed, op.target, op.tween);
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 "setClassStyle":
return handleSetClassStyle(parsed, op.selector, op.styles);
case "addLabel":
return handleAddLabel(parsed, op.name, op.position);
case "removeLabel":
return handleRemoveLabel(parsed, op.name);
case "setClassStyle":
return handleSetClassStyle(parsed, op.selector, op.styles);
default:
throw new UnsupportedOpError((op as EditOp).type);
}
}
@@ -689,6 +714,20 @@ function handleSetGsapTween(
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 {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
@@ -698,6 +737,24 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut
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) {
const script = getGsapScript(parsed.document);
if (!script) return null;
@@ -772,6 +829,28 @@ function handleAddGsapKeyframe(
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(
parsed: ParsedDocument,
animationId: string,
@@ -873,7 +952,9 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "setGsapKeyframe":
case "addGsapKeyframe":
case "removeGsapKeyframe":
case "removeGsapProperty":
case "removeGsapTween":
case "deleteAllForSelector":
case "removeLabel":
if (getGsapScript(parsed.document) === null)
return canErr(
+3
View File
@@ -102,7 +102,10 @@ export type EditOp =
value: Record<string, unknown>;
}
| { 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: "deleteAllForSelector"; selector: string }
| { type: "addLabel"; name: string; position: number }
| { type: "removeLabel"; name: string };
@@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useElementLifecycleOps } from "./useElementLifecycleOps";
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
// Re-export so existing consumers keep their import path
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
// ── Helpers ──
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
@@ -45,8 +42,6 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}
export type { PersistDomEditOperations } from "./domEditCommitTypes";
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -2,7 +2,11 @@ import { useCallback } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { roundTo3 } from "../utils/rounding";
import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover";
import {
sdkGsapTweenPersist,
sdkGsapDeleteAllForSelectorPersist,
type CutoverDeps,
} from "../utils/sdkCutover";
import {
assignGsapTargetAutoIdIfNeeded,
ensureElementAddressable,
@@ -80,15 +84,25 @@ export function useGsapAnimationOps({
);
const deleteAllForSelector = useCallback(
(selection: DomEditSelection, targetSelector: string) => {
// ponytail: no SDK op for delete-all-for-selector; stays server-authoritative
async (selection: DomEditSelection, targetSelector: string) => {
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(
selection,
{ type: "delete-all-for-selector", targetSelector },
{ label: "Delete all animations for element" },
);
},
[commitMutation],
[commitMutation, activeCompPath, sdkSession, sdkDeps],
);
// 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 { DomEditSelection } from "../components/editor/domEditingTypes";
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 { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
@@ -151,9 +155,6 @@ export function useGsapKeyframeOps({
const removeKeyframe = useCallback(
(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 mutation = { type: "remove-keyframe", animationId, percentage };
void executeOptimisticKeyframeCacheUpdate({
@@ -162,19 +163,31 @@ export function useGsapKeyframeOps({
apply: (prev) => ({
...prev,
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: () =>
commitMutation(selection, mutation, {
persist: async () => {
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}%`,
softReload: true,
}),
});
},
}).catch((error) => {
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
});
},
[activeCompPath, commitMutation, trackGsapSaveFailure],
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
);
const convertToKeyframes = useCallback(
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useRef } from "react";
import type { Composition } from "@hyperframes/sdk";
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 type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
@@ -110,16 +114,47 @@ export function useGsapPropertyDebounce(
[commitMutationSafely, sdk],
);
const removeGsapProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
// ponytail: null ≠ removal in upsertProp; remove-property stays server-authoritative
commitMutationSafely(
selection,
{ type: "remove-property", animationId, property },
{ label: `Remove GSAP ${property}` },
);
const removeProperty = useCallback(
async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => {
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const handled = await sdkGsapRemovePropertyPersist(
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(
@@ -185,15 +220,9 @@ export function useGsapPropertyDebounce(
);
const removeGsapFromProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
// ponytail: null ≠ removal in upsertProp; remove-from-property stays server-authoritative
commitMutationSafely(
selection,
{ type: "remove-from-property", animationId, property },
{ label: `Remove GSAP from-${property}` },
);
},
[commitMutationSafely],
(selection: DomEditSelection, animationId: string, property: string) =>
removeProperty(selection, animationId, property, true),
[removeProperty],
);
return {
+70 -32
View File
@@ -192,32 +192,44 @@ type SdkGsapTweenOp =
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
| { kind: "remove"; animationId: string };
export async function sdkGsapTweenPersist(
export function sdkGsapTweenPersist(
targetPath: string,
op: SdkGsapTweenOp,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
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> {
if (!sdkSession) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
if (op.kind === "add" && !sdkSession.getElement(op.target)) return false;
const before = sdkSession.serialize();
sdkSession.batch(() => {
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);
}
});
dispatch(sdkSession);
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;
await persistSdkSerialize(after, targetPath, before, deps, options);
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
@@ -228,7 +240,7 @@ export async function sdkGsapTweenPersist(
}
}
export async function sdkGsapKeyframePersist(
export function sdkGsapKeyframePersist(
targetPath: string,
animationId: string,
position: number,
@@ -237,22 +249,48 @@ export async function sdkGsapKeyframePersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
if (!sdkSession) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
const before = sdkSession.serialize();
sdkSession.batch(() =>
sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }),
);
const after = sdkSession.serialize();
if (after === before) return false;
await persistSdkSerialize(after, targetPath, before, deps, options);
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
return false;
}
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
);
}
export function sdkGsapRemoveKeyframePersist(
targetPath: string,
animationId: string,
percentage: number,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
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(