fix(studio): plain elements keep their place and size when keyframed (#5045)

* test(studio): bench a plain element animated through auto-record and Add keyframe

* fix(studio): seed a first tween from the offset a plain move left in CSS translate

* test(studio): bench resize then drag on a GSAP tween, root and nested

* fix(studio): a resize under auto-record keys size at the playhead, held at all times

* fix(studio): keyframe tweens Studio writes end on their last key, so a seek matches playback

* test(studio): keep the plain-to-keyframes moves inside the nested frame

* fix(studio): a resize moves its anchor from the drag stamp it read at release

* fix(studio): an undo shown in place is no longer replaced by an older reload

* refactor: split the trailing keyframe trim and drop an unused GSAP reader

* test(studio): settle the resize-then-drag bench steps so a two-save resize is one undo

* fix(studio): a preview reload waits for an undo shown before its write lands

* fix(studio): Add keyframe reads a subtracted calc() translate with its sign

* refactor: tidy the keyframe hold, trim and resize code after review

* fix(parsers): the keyframe trim leaves looping, chained and relatively placed tweens alone

* fix(parsers): a size hold pins only a lone key, so an edit never changes frames left alone

* fix(studio): every undo applied in place counts as a preview change, script-only ones too

* fix(studio): a resize keys size only when a move or size is keyframed, not a fade

* test(studio): a bench step whose save never lands fails the case

* fix(parsers): holds and trims change only the tweens an edit touches

* test(studio): bank the plain-to-keyframes and resize-then-drag bench cases
This commit is contained in:
Miguel Ángel
2026-10-05 15:46:50 +00:00
committed by GitHub
parent 061a5c0265
commit 5c7f11ab6d
34 changed files with 3118 additions and 2187 deletions
+46 -9
View File
@@ -139,17 +139,45 @@ function knownStart(animation: GsapAnimation): number | undefined {
return typeof animation.position === "number" ? animation.position : undefined;
}
export interface HoldScope {
touched: (animation: GsapAnimation) => boolean;
held: (selector: string) => ReadonlyMap<string, unknown>;
}
const tweenSignature = (a: GsapAnimation) =>
JSON.stringify([a.targetSelector, a.method, a.position, a.duration, a.properties, a.keyframes]);
/** The tweens an edit touched (not in `previous`; none without it), and what the script's holds already pin. */
export function holdScope(
before: readonly GsapAnimation[],
previous: readonly GsapAnimation[] | null,
): HoldScope {
const kept = new Set(previous?.map(tweenSignature));
const held = new Map<string, Map<string, unknown>>();
for (const a of before.filter((b) => b.method === "set" && b.properties?.data === "hf-hold")) {
const props = held.get(a.targetSelector) ?? new Map<string, unknown>();
for (const [property, value] of Object.entries(a.properties)) props.set(property, value);
held.set(a.targetSelector, props);
}
return {
touched: (a) => previous !== null && !kept.has(tweenSignature(a)),
held: (selector) => held.get(selector) ?? new Map(),
};
}
/**
* What a Studio hold pins from t=0 before a later keyframed tween: its 0% keyframe's position props,
* minus those an earlier timeline tween on the target writes (a global `gsap.set` is a base value).
* What a Studio hold pins from t=0: a keyframed tween's 0% position props before a later start, and the position and
* size of a lone key (GSAP renders none) only where this edit made it or a hold already pins it, minus earlier tweens.
*/
export function positionHoldForAnimation(
export function keyframeHoldForAnimation(
animation: GsapAnimation,
animations: readonly GsapAnimation[],
scope: HoldScope,
): Record<string, number> | null {
if (!animation.keyframes) return null;
const start = knownStart(animation) ?? 0;
if (!(start > 0.001)) return null;
const start = knownStart(animation);
const lone = animation.keyframes.keyframes.length === 1;
if (start === undefined || (!(start > 0.001) && !lone)) return null;
const atStart = animation.keyframes.keyframes.find((keyframe) => keyframe.percentage === 0);
if (!atStart) return null;
// A tween whose start the parser could not resolve (a label, say) is not known to come first.
@@ -163,13 +191,22 @@ export function positionHoldForAnimation(
otherStart < start - 0.001
);
});
const position: Record<string, number> = {};
const touched = scope.touched(animation);
const pinned = scope.held(animation.targetSelector);
// An untouched tween keeps only the hold it made: one with its own first value.
const kept = (property: string, value: unknown) =>
touched ? pinned.has(property) : pinned.get(property) === value;
const pins = (group: PropertyGroupName, property: string, value: unknown) =>
(group === "position" && start > 0.001) ||
((group === "position" || group === "size") && ((lone && touched) || kept(property, value)));
const hold: Record<string, number> = {};
for (const [property, value] of Object.entries(atStart.properties)) {
if (classifyPropertyGroup(property) !== "position" || typeof value !== "number") continue;
if (!pins(classifyPropertyGroup(property), property, value) || typeof value !== "number")
continue;
if (earlier.some((other) => writesProperty(other, property))) continue;
position[property] = value;
hold[property] = value;
}
return Object.keys(position).length > 0 ? position : null;
return Object.keys(hold).length > 0 ? hold : null;
}
export const SUPPORTED_EASES = [
+55
View File
@@ -1792,6 +1792,61 @@ describe("keyframe mutations", () => {
expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold");
});
it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])("%s: holds a lone size keyframe from t=0, wherever its tween starts", (_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
for (const start of [0, 1.5]) {
const script =
`const tl = gsap.timeline({ paused: true });\n` +
`tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 3 }, ${start});`;
const hold = parseGsapScript(sync(script, timeline)).animations.find(
(a) => a.method === "set",
);
expect(hold!.position).toBe(0);
expect(hold!.properties).toEqual({ width: 440, height: 294, data: "hf-hold" });
}
});
it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])(
"%s: pins no size from t=0 for a tween of two size keys or a lone key after a size tween",
(_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const keys = `"0%": { width: 100 }, "100%": { width: 400 }`;
const twoKeys = `${timeline}tl.to("#s", { keyframes: { ${keys} }, duration: 1 }, 2);`;
expect(sync(twoKeys, timeline)).not.toContain("hf-hold");
const atLabel = `${timeline}tl.to("#s", { width: 300, duration: 1 }, 1);\ntl.to("#s", { keyframes: { "0%": { width: 500 } }, duration: 1 }, "later");`;
expect(sync(atLabel, timeline)).not.toContain("hf-hold");
},
);
it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])(
"%s: keeps a size hold when its lone key gains a second, and adds none to a tween left alone",
(_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const held = `tl.set("#s", { width: 440, height: 294, data: "hf-hold" }, 0);\n`;
const lone = `tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 2 }, 3);`;
const two = `tl.to("#s", { keyframes: { "0%": { width: 380, height: 250 }, "100%": { width: 440, height: 294 } }, duration: 1 }, 2);`;
const hold = parseGsapScript(
sync(timeline + held + two, timeline + held + lone),
).animations.find((a) => a.method === "set");
expect(hold?.properties).toEqual({ width: 380, height: 250, data: "hf-hold" });
const untouched = `${timeline}tl.to("#u", { keyframes: { "0%": { width: 500 } }, duration: 1 }, 1);\n`;
const edited = `${untouched}tl.to("#v", { opacity: 1, duration: 1 }, 0);`;
const deleted = `${timeline}${held}tl.to("#s", { keyframes: { "0%": { width: 600 }, "100%": { width: 700 } }, duration: 1 }, 6);`;
expect(sync(deleted, deleted + `\n${lone}`)).not.toContain("hf-hold");
expect(sync(edited, untouched)).not.toContain("hf-hold");
},
);
it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => {
const opacity =
`const tl = gsap.timeline({ paused: true });\n` +
+20 -7
View File
@@ -55,7 +55,8 @@ import {
classifyTweenPropertyGroup,
GSAP_DEFAULT_DURATION,
isXYPositionWrite,
positionHoldForAnimation,
holdScope,
keyframeHoldForAnimation,
} from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import { BUILTIN_VAR_KEYS, DROPPED_VAR_KEYS, EXTRAS_KEYS, isTweenConfigKey } from "./gsapConstants";
@@ -1862,11 +1863,19 @@ export function isStudioHoldSet(anim: GsapAnimation): boolean {
* so this pass owns it: every call wipes the prior holds and recomputes from the
* current keyframes, keeping them in sync as keyframes are added/moved/deleted.
*
* Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale
* keep their authored pre-tween behavior. A tween already starting at 0 needs no
* hold (no gap before it).
* Idempotent. Only position and size props are held — opacity/scale keep their
* authored pre-tween behavior. A tween already starting at 0 needs no hold, unless
* it is a lone keyframe, which GSAP never renders by itself.
*/
export function syncPositionHoldsBeforeKeyframes(script: string): string {
function animationsOf(script: string): GsapAnimation[] | null {
try {
return parseGsapScript(script).animations;
} catch {
return null;
}
}
export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
let parsed: ParsedGsap;
try {
parsed = parseGsapScript(script);
@@ -1878,15 +1887,19 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id);
for (const id of staleHoldIds) result = removeAnimationFromScript(result, id);
// 2. Re-add a hold for each position-keyframed tween that starts after t=0.
// 2. Re-add a hold for each keyframed tween keyframeHoldForAnimation pins.
let reparsed: ParsedGsap;
try {
reparsed = parseGsapScript(result);
} catch {
return result;
}
const scope = holdScope(
parsed.animations,
previous === undefined ? null : animationsOf(previous),
);
for (const anim of reparsed.animations) {
const posProps = positionHoldForAnimation(anim, reparsed.animations);
const posProps = keyframeHoldForAnimation(anim, reparsed.animations, scope);
if (!posProps) continue;
result = insertInheritedStateSet(result, anim.targetSelector, 0, {
...posProps,
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { trimTrailingKeyframeSpans } from "./gsapWriterAcorn.js";
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const tween = (keys: string, duration = 3, extra = "") =>
`tl.to("#t", { keyframes: { ${keys} }, duration: ${duration}${extra} }, 1);`;
const shortOfEnd = tween(`"0%": { x: 300 }, "33.333%": { x: 297 }, "66.667%": { x: 48 }`);
const keyed = (script: string) => {
const anim = parseGsapScriptAcorn(script).animations.find((a) => a.keyframes)!;
return {
duration: anim.duration,
keys: anim.keyframes!.keyframes.map((k) => [k.percentage, k.properties.x]),
};
};
describe("trimTrailingKeyframeSpans", () => {
it("ends a tween this mutation wrote on its last key, keeping each key's time", () => {
const out = trimTrailingKeyframeSpans(timeline, timeline + shortOfEnd);
expect(keyed(out)).toEqual({
duration: 2,
keys: [
[0, 300],
[49.999, 297],
[100, 48],
],
});
});
it("trims beside a static gsap.set, which no tween's length moves", () => {
const out = trimTrailingKeyframeSpans(
timeline,
`${timeline}gsap.set("#g", { x: 5 });\n${shortOfEnd}`,
);
expect(keyed(out).duration).toBe(2);
});
it("leaves a looping timeline, whose cycle the tail is part of", () => {
const looping = `const tl = gsap.timeline({ paused: true, repeat: -1 });\n`;
expect(trimTrailingKeyframeSpans(looping, looping + shortOfEnd)).toBe(looping + shortOfEnd);
});
it("leaves a chained tween the mutation did not touch when it edits the link before", () => {
const chain = (x: number) =>
`tl.to("#a", { x: ${x}, duration: 1 }, 0).to("#t", { keyframes: { "0%": { x: 300 }, "50%": { x: 48 } }, duration: 2 }, 1);`;
expect(trimTrailingKeyframeSpans(timeline + chain(1), timeline + chain(2))).toBe(
timeline + chain(2),
);
});
it("leaves a tween the mutation did not touch, and the bytes around it", () => {
const script = timeline + shortOfEnd;
expect(trimTrailingKeyframeSpans(script, script)).toBe(script);
});
it.each([
["a lone key, which the hold renders", tween(`"0%": { x: 300 }`)],
["a tween that already ends on its last key", tween(`"0%": { x: 300 }, "100%": { x: 48 }`)],
[
"an outer ease, whose curve the tail is part of",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, ease: "power2.in"`),
],
[
"a looping tween, whose tail is part of every cycle",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", repeat: -1"),
],
[
"every tween when one is placed after another's end",
`${shortOfEnd}\ntl.to("#u", { opacity: 1, duration: 1 }, ">");`,
],
["a yoyo tween", tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", yoyo: true")],
[
"a tween whose repeat key is quoted",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, "repeat": -1`),
],
["every tween when a label sits at the timeline's end", `${shortOfEnd}\ntl.addLabel("end");`],
["every tween when the timeline is set to repeat later", `${shortOfEnd}\ntl.repeat(-1);`],
])("leaves %s", (_, written) => {
expect(trimTrailingKeyframeSpans(timeline, timeline + written)).toBe(timeline + written);
});
});
+83 -3
View File
@@ -29,7 +29,8 @@ import {
classifyPropertyGroup,
isTweenConfigKey,
isXYPositionWrite,
positionHoldForAnimation,
holdScope,
keyframeHoldForAnimation,
} from "./gsapConstants.js";
import type { PropertyGroupName } from "./gsapConstants.js";
import {
@@ -2490,15 +2491,21 @@ function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite):
}
/** Acorn-native, byte-preserving hold synchronization used after mutations. */
export function syncPositionHoldsBeforeKeyframes(script: string): string {
export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const tweensOf = (text: string) =>
parseGsapScriptAcornForWrite(text)?.located.map((entry) => entry.animation) ?? null;
const scope = holdScope(
parsed.located.map((entry) => entry.animation),
previous === undefined ? null : tweensOf(previous),
);
let result = removeStudioHoldSets(script, parsed);
const current = parseGsapScriptAcornForWrite(result);
if (!current) return result;
const animations = current.located.map((entry) => entry.animation);
for (const animation of animations) {
const position = positionHoldForAnimation(animation, animations);
const position = keyframeHoldForAnimation(animation, animations, scope);
if (!position) continue;
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
...position,
@@ -2508,6 +2515,79 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
return result;
}
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
const LINEAR_RUN = new Set(["none", "linear"]);
// ponytail: any .add( .duration( repeat: or yoyo: in the script skips the trim, classList.add included.
const PLACED_BY_TIMELINE =
/\.(?:add|addLabel|addPause|call|repeat|yoyo|duration|timeScale)\s*\(|\b(?:repeat|yoyo)\s*:/;
/** A percentage keyframe list whose timing is known and whose run is linear, so a trim keeps its render. */
function linearPercentageKeys(animation: GsapAnimation) {
const data = animation.keyframes;
if (data?.format !== "percentage" || data.fromMotionPath || animation.arcPath) return null;
if (animation.durationUnresolved || !LINEAR_RUN.has(animation.ease || "none")) return null;
return data.keyframes;
}
const loops = (animation: GsapAnimation) =>
Boolean(animation.extras?.repeat || animation.extras?.yoyo);
function trimmableKeyframes(animation: GsapAnimation) {
const keyframes = loops(animation) ? null : linearPercentageKeys(animation);
const { duration, position } = animation;
if (!keyframes || typeof position !== "number" || typeof duration !== "number") return null;
return duration > 0 ? { keyframes, duration, position } : null;
}
/** The keyless tail of a keyframe tween, as the remap that ends it on its last key; null when it has none. */
function trailingSpanTrim(animation: GsapAnimation) {
const tween = trimmableKeyframes(animation);
if (!tween) return null;
const last = Math.max(...tween.keyframes.map((keyframe) => keyframe.percentage));
if (tween.keyframes.length < 2 || !(last > 0) || last >= 99.999) return null;
return {
position: tween.position,
duration: Math.round(tween.duration * last * 10) / 1000,
pctRemap: tween.keyframes.map(({ percentage: from }) => ({
from,
to: roundPct((from / last) * 100),
})),
};
}
/** GSAP 3.15 stretches a keyframe tween that stops short of 100% on its first render, unlike once played:
* each tween this mutation wrote or edited ends on its last key instead. Tweens it left alone keep their tail. */
export function trimTrailingKeyframeSpans(previous: string, script: string): string {
if (script === previous) return script;
const located = parseGsapScriptAcornForWrite(script)?.located ?? [];
// A shorter tween moves what is placed after its end ("+=", ">", no position, add/call/addLabel).
const placedAfter = ({ animation: a }: { animation: GsapAnimation }) =>
!a.global && (a.implicitPosition || typeof a.position !== "number");
if (PLACED_BY_TIMELINE.test(script) || located.some(placedAfter)) return script;
const trims = located.flatMap((entry) => {
const trim = trailingSpanTrim(entry.animation);
return trim ? [{ entry, trim }] : [];
});
if (!trims.length) return script;
const source = (text: string, { call }: { call: TweenCallInfo }) =>
`${call.method}${text.slice(call.node.arguments[0]?.start, call.node.end)}`;
const previousTweens = parseGsapScriptAcornForWrite(previous)?.located ?? [];
const untouched = new Set(previousTweens.map((entry) => source(previous, entry)));
let result = script;
for (const { entry, trim } of trims) {
if (untouched.has(source(script, entry))) continue;
result = resizeKeyframedTweenInScript(
result,
entry.id,
trim.position,
trim.duration,
trim.pctRemap,
);
}
return result;
}
/**
* Compute, in forward (timeline) order, the inherited-props baseline available
* BEFORE each matching tween, plus the final cumulative state at the split point.
@@ -1,8 +1,10 @@
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { parseHTML } from "linkedom";
import { createProjectSignature } from "./projectSignature.js";
import { rootHeadContent } from "./subComposition.js";
import { resolveWithinProject } from "./safePath.js";
import { descendants } from "./compositionInsertion.js";
const ROOT_COMPOSITION = "index.html";
// A regex, not a parser: a stray match (say, inside a script string) only widens what a
@@ -49,6 +51,33 @@ function closureOf(read: SourceReader, compPath: string): Set<string> {
return closure;
}
/** Whether `compPath` lasts as long as its own timeline (so a trim there could end the film early). */
export function lengthIsTimeline(projectDir: string, compPath: string): boolean {
const read = projectReader(projectDir);
const documents = new Map<string, Document>();
const elements = (path: string, selector: string) => {
let document = documents.get(path);
if (!document) documents.set(path, (document = parseHTML(read(path) ?? "").document));
return descendants(document, selector);
};
const comp = normalizeSource(compPath) ?? compPath;
const unsized = (path: string) => {
const [root] = elements(path, "[data-composition-id]");
return Boolean(root && !root.hasAttribute("data-duration"));
};
// An unsized root lasts as long as every timeline it plays, its compositions' included.
if (unsized(ROOT_COMPOSITION)) return true;
const hosts = [...closureOf(read, ROOT_COMPOSITION)].flatMap((path) =>
elements(path, "[data-composition-src]").filter(
(host) => normalizeSource(host.getAttribute("data-composition-src") ?? "") === comp,
),
);
if (comp === ROOT_COMPOSITION || hosts.length === 0) return unsized(comp);
return hosts.some(
(host) => !host.hasAttribute("data-duration") && !host.hasAttribute("data-end"),
);
}
// ponytail: this and projectSignature's per-exclusion cache keep one small entry per (project,
// composition) ever thumbnailed, never evicted; LRU them if a server ever holds thousands.
const inputSignatures = new Map<string, { projectSignature: string; inputSignature: string }>();
@@ -15,7 +15,7 @@ export class CompositionInsertionError extends Error {
}
}
function descendants(root: Document | Element, selector: string): Element[] {
export function descendants(root: Document | Element, selector: string): Element[] {
const found = Array.from(root.querySelectorAll(selector));
for (const template of root.querySelectorAll("template")) {
found.push(...descendants(template, selector));
@@ -2669,6 +2669,127 @@ tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, dura
expect(result.after).not.toContain("motionPath");
});
const trimmed = ['"100%": { x: 48 }', "duration: 1 }, 1);"];
const kept = ['"33.333%": { x: 48 }', "duration: 3 }, 1);"];
const host = (length: string, root = 'data-duration="8"') => ({
"index.html": `<!DOCTYPE html><html><body><div data-composition-id="main" ${root}><div data-composition-id="sub" data-composition-src="sub.html" ${length}></div></div></body></html>`,
});
const scopedStyle = '<head><style>[data-composition-id="main"] div { color: red }</style></head>';
it.each([
[
"ends the tween it edits on its last key",
"index.html",
'data-composition-id="main" data-duration="8"',
{},
trimmed,
],
[
"ends it past a scoped style naming the root",
"index.html",
'data-composition-id="main" data-duration="8"',
{},
trimmed,
scopedStyle,
],
["ends it in a file with no composition root", "index.html", 'data-duration="8"', {}, trimmed],
[
"keeps the tail of a root whose length is its timeline",
"index.html",
'data-composition-id="main"',
{},
kept,
],
[
"ends it in a sub-composition whose host gives its length",
"sub.html",
'data-composition-id="sub"',
host('data-duration="4"'),
trimmed,
],
[
"ends it in a sub-composition whose host gives its end",
"sub.html",
'data-composition-id="sub"',
host('data-end="4"'),
trimmed,
],
[
"keeps the tail of a sub-composition whose host gives none",
"sub.html",
'data-composition-id="sub"',
host(""),
kept,
],
[
"keeps the tail of a sub-composition in an unsized root",
"sub.html",
'data-composition-id="sub"',
host('data-duration="4"', ""),
kept,
],
[
"keeps the tail of an unsized root no host mounts",
"demo.html",
'data-composition-id="main"',
{},
kept,
],
[
"keeps the tail of a composition its template-wrapped host gives no length",
"inner.html",
"",
{
...host('data-duration="4"'),
"sub.html":
'<template><div data-composition-id="sub"><div data-composition-src="inner.html"></div></div></template>',
},
kept,
],
])(
"a keyframe write %s, and leaves other tails alone",
async (_, file, root, mounts, written, head = "") => {
const projectDir = createProjectDir();
for (const [name, html] of Object.entries(mounts)) writeHtml(projectDir, name, html);
const authored = `tl.to("#other", { keyframes: { "0%": { x: 0 }, "50%": { x: 90 } }, duration: 2 }, 0);`;
writeHtml(
projectDir,
file,
`<!DOCTYPE html><html>${head}<body ${root}>
<div id="box"></div><div id="other"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { keyframes: { "0%": { x: 300 } }, duration: 3 }, 1);
${authored}
</script>
</body></html>`,
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, file);
const res = await app.request(`http://localhost/projects/demo/gsap-mutations/${file}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: "#box",
position: 1,
duration: 3,
keyframes: [
{ percentage: 0, properties: { x: 300 } },
{ percentage: 33.333, properties: { x: 48 } },
],
}),
});
const result = (await res.json()) as { ok: boolean; after: string };
expect(result.ok).toBe(true);
for (const text of written) expect(result.after).toContain(text);
expect(result.after).toContain(authored);
},
);
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
+9 -4
View File
@@ -23,6 +23,7 @@ import {
import { resolve, dirname, join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isAudioFile } from "../helpers/mime.js";
import { lengthIsTimeline } from "../helpers/compositionInputs.js";
import { createFileAtomically, replaceFileAtomically } from "@hyperframes/core/atomic-file";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
@@ -80,6 +81,7 @@ import {
type ClipTweenRetime,
dedupePositionWritesInScript,
syncPositionHoldsBeforeKeyframes,
trimTrailingKeyframeSpans,
clipQueryRoot,
} from "@hyperframes/parsers/gsap-writer-acorn";
import {
@@ -1541,6 +1543,7 @@ async function applyGsapMutations(
const prepared = await prepareGsapMutationScript(c, res, firstMutation);
if (prepared instanceof Response) return prepared;
const { html, beforeHtml, block } = prepared;
let lengthIsAuthored: boolean | undefined;
const initialScript = block.scriptText;
const skippedSelectors = new Set<string>();
@@ -1575,10 +1578,12 @@ async function applyGsapMutations(
for (const selector of result.skippedSelectors) skippedSelectors.add(selector);
}
if (HOLD_SYNC_MUTATION_TYPES.has(mutation.type)) {
lengthIsAuthored ??= !lengthIsTimeline(res.project.dir, res.filePath);
if (lengthIsAuthored) newScript = trimTrailingKeyframeSpans(previousScript, newScript);
newScript =
writer === "acorn"
? syncPositionHoldsBeforeKeyframes(newScript)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(newScript);
? syncPositionHoldsBeforeKeyframes(newScript, previousScript)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(newScript, previousScript);
}
mutationChanges.push(newScript !== previousScript);
block.scriptText = newScript;
@@ -2385,8 +2390,8 @@ async function foldAtomicCutFile(
if (script !== block.scriptText) {
script =
writer === "acorn"
? syncPositionHoldsBeforeKeyframes(script)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script);
? syncPositionHoldsBeforeKeyframes(script, block.scriptText)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script, block.scriptText);
after = block.replaceScript(script);
}
}
@@ -50,14 +50,18 @@ describe("GSAP writer capability matrix", () => {
);
expect(acornBody).not.toContain("loadGsapParser");
expect(mutationHelperBody).toContain('writer === "acorn"');
expect(mutationHelperBody).toContain("? syncPositionHoldsBeforeKeyframes(newScript)");
expect(mutationHelperBody).toContain(
"? syncPositionHoldsBeforeKeyframes(newScript, previousScript)",
);
expect(mutationHelperBody).toContain(
"(await loadGsapParser()).syncPositionHoldsBeforeKeyframes",
);
expect(atomicCutHelperBody).toContain('writer === "acorn"');
expect(atomicCutHelperBody).toContain("? syncPositionHoldsBeforeKeyframes(script)");
expect(atomicCutHelperBody).toContain(
"(await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script)",
"? syncPositionHoldsBeforeKeyframes(script, block.scriptText)",
);
expect(atomicCutHelperBody).toContain(
"(await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script, block.scriptText)",
);
});
@@ -91,10 +91,14 @@ export function studioManualEditSavesIn(doc: Document): number {
return gestureSaves.get(doc) ?? 0;
}
export function countStudioPreviewChange(doc: Document): void {
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
}
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
const doc = element.ownerDocument;
const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
const count = () => countStudioPreviewChange(doc);
count();
const result = save();
void Promise.resolve(result).then(count, count);
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { readTranslatePx } from "./plainTranslate";
import { describe, expect, it, vi } from "vitest";
import { readTranslatePx, readTranslatePxLeavingPercent } from "./plainTranslate";
function box(style: string): HTMLElement {
const element = document.createElement("div");
@@ -76,3 +76,22 @@ describe("readTranslatePx", () => {
},
);
});
describe("readTranslatePxLeavingPercent", () => {
it("seeds a first tween from the px a plain move left in CSS translate, leaving % to xPercent", () => {
const el = document.body.appendChild(document.createElement("div"));
// happy-dom computes no `translate`; Chromium resolves it to these strings.
const computed = (translate: string) =>
vi.spyOn(window, "getComputedStyle").mockReturnValue({ translate } as CSSStyleDeclaration);
computed("299.893px 203.262px");
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 299.893, y: 203.262 });
computed("calc(-50% + 120px) -50%");
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 120, y: 0 });
computed("calc(-50% - 120px) calc(min(10px, 20px) + 5px)");
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: -120, y: 15 });
computed("none");
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 0, y: 0 });
vi.restoreAllMocks();
el.remove();
});
});
@@ -100,6 +100,13 @@ export function readTranslatePx(el: HTMLElement): Point {
};
}
export function readTranslatePxLeavingPercent(el: HTMLElement): Point {
const value = el.ownerDocument.defaultView?.getComputedStyle(el).translate || "none";
const [x = "0px", y = "0px"] = value === "none" ? [] : splitTopLevelWhitespace(value);
const px = (part: string) => roundTo3(evaluateLength(part, 0)) || 0;
return { x: px(x), y: px(y) };
}
export const UNREADABLE_TRANSLATE =
"Studio can't read this layer's translate. Move it in the Code tab.";
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import { expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { freezeDragStamp, restoreDragOffset } from "./draggedGsapPosition";
import { commitStaticGsapPosition } from "./gsapDragCommit";
function stamp(element: HTMLElement, base: { x: number; y: number }) {
element.setAttribute("data-hf-drag-gsap-base-x", String(base.x));
element.setAttribute("data-hf-drag-gsap-base-y", String(base.y));
element.setAttribute("data-hf-drag-initial-offset-x", "0");
element.setAttribute("data-hf-drag-initial-offset-y", "0");
}
// A resize released at x 60 moves its anchor by -50; the next drag presses before that write is planned.
it("a commit that outlives its gesture uses the gesture's stamp, not the next one's", async () => {
const element = document.createElement("div");
stamp(element, { x: 60, y: 37.5 });
const frozen = freezeDragStamp(element);
stamp(element, { x: 10, y: 4 });
const commitMutation = vi.fn();
await commitStaticGsapPosition(
{ id: "target", selector: "#target", element } as DomEditSelection,
{ x: -50, y: -33.5 },
{ x: 0, y: 0 },
"#target",
null,
{ commitMutation, stamp: frozen },
);
expect(commitMutation.mock.calls[0]![1].properties).toEqual({ x: 10, y: 4 });
restoreDragOffset(element, frozen);
expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe("0");
});
@@ -36,10 +36,35 @@ export function readCssRotation(element: HTMLElement, withRotate = true): number
}
}
export interface DragStamp {
origX: number;
origY: number;
baseX: number;
baseY: number;
frozen?: boolean;
}
/** The drag-start attributes as they are now; NaN for an absent base. */
export function readDragStamp(element: HTMLElement): DragStamp {
const read = (name: string) => Number.parseFloat(element.getAttribute(name) ?? "");
return {
origX: read("data-hf-drag-initial-offset-x") || 0,
origY: read("data-hf-drag-initial-offset-y") || 0,
baseX: read("data-hf-drag-gsap-base-x"),
baseY: read("data-hf-drag-gsap-base-y"),
};
}
/** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
...readDragStamp(element),
frozen: true,
});
/**
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
* element's rotation and its drag-start base pose. Reads the drag-start
* attributes stamped by `createManualOffsetDragMember`
* stamp (`readDragStamp` by default) set by `createManualOffsetDragMember`
* (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase`
* is used when the base attributes are absent (e.g. a static element that GSAP
* hasn't given an x/y yet).
@@ -52,22 +77,19 @@ export function computeDraggedGsapPosition(
element: HTMLElement,
studioOffset: { x: number; y: number },
fallbackBase: { x: number; y: number },
stamp: DragStamp = readDragStamp(element),
): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } {
const rotStyle = element.style.getPropertyValue("--hf-studio-rotation");
const rotDeg = Number.parseFloat(rotStyle) || 0;
const rad = (-rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
const deltaX = studioOffset.x - origX;
const deltaY = studioOffset.y - origY;
const deltaX = studioOffset.x - stamp.origX;
const deltaY = studioOffset.y - stamp.origY;
const adjX = deltaX * cos - deltaY * sin;
const adjY = deltaX * sin + deltaY * cos;
const parsedBaseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const parsedBaseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
const baseGsapX = Number.isFinite(stamp.baseX) ? stamp.baseX : fallbackBase.x;
const baseGsapY = Number.isFinite(stamp.baseY) ? stamp.baseY : fallbackBase.y;
return {
newX: roundTo3(baseGsapX + adjX),
newY: roundTo3(baseGsapY + adjY),
@@ -76,12 +98,15 @@ export function computeDraggedGsapPosition(
};
}
/** Puts the drag's preview offset back once the written position renders instead. */
export function restoreDragOffset(element: HTMLElement): void {
const origin = (axis: "x" | "y") =>
Number.parseFloat(element.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "") || 0;
element.style.setProperty("--hf-studio-offset-x", `${origin("x")}px`);
element.style.setProperty("--hf-studio-offset-y", `${origin("y")}px`);
/** Puts the drag's preview offset back once the written position renders instead. A frozen stamp's
* gesture is over: the attributes belong to the one after it. */
export function restoreDragOffset(
element: HTMLElement,
stamp: DragStamp = readDragStamp(element),
): void {
if (stamp.frozen) return;
element.style.setProperty("--hf-studio-offset-x", `${stamp.origX}px`);
element.style.setProperty("--hf-studio-offset-y", `${stamp.origY}px`);
element.removeAttribute("data-hf-drag-initial-offset-x");
element.removeAttribute("data-hf-drag-initial-offset-y");
}
+17 -10
View File
@@ -12,7 +12,12 @@ import {
} from "../utils/globalTimeCompiler";
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import {
computeDraggedGsapPosition,
readDragStamp,
restoreDragOffset,
type DragStamp,
} from "./draggedGsapPosition";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
@@ -26,6 +31,8 @@ export {
export interface GsapDragCommitCallbacks {
commitMutation: CommitMutation;
fetchAnimations?: () => Promise<GsapAnimation[]>;
/** The gesture's drag stamp, when its commit can outlive it; else read from the element. */
stamp?: DragStamp;
}
/**
@@ -169,7 +176,12 @@ export async function commitStaticGsapPosition(
existingSet: GsapAnimation | null,
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
const { newX, newY } = computeDraggedGsapPosition(
selection.element,
studioOffset,
gsapPos,
callbacks.stamp,
);
if (existingSet) {
if (existingSet.keyframes) {
// Keyframed zero-duration hold (drag-path corruption): can't update-property
@@ -344,22 +356,17 @@ export async function commitWholePathOffset(
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
const el = selection.element;
const stamp = callbacks.stamp ?? readDragStamp(el);
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
el,
studioOffset,
gsapPos,
stamp,
);
const deltaX = newX - baseGsapX;
// fallow-ignore-next-line code-duplication
const deltaY = newY - baseGsapY;
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
const restoreOffset = () => {
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
el.removeAttribute("data-hf-drag-initial-offset-x");
el.removeAttribute("data-hf-drag-initial-offset-y");
};
const restoreOffset = () => restoreDragOffset(el, stamp);
// fallow-ignore-next-line code-duplication
let effectiveAnim = anim;
@@ -9,7 +9,11 @@ import {
import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
import { roundTo3 } from "../utils/rounding";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import {
computeDraggedGsapPosition,
readDragStamp,
restoreDragOffset,
} from "./draggedGsapPosition";
import {
type GsapDragCommitCallbacks,
computeCurrentPercentage,
@@ -117,20 +121,15 @@ export async function commitGsapPositionFromDrag(
callbacks: GsapDragCommitCallbacks,
): Promise<GsapEditOutcome> {
const el = selection.element;
const stamp = callbacks.stamp ?? readDragStamp(el);
// fallow-ignore-next-line code-duplication
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
el,
studioOffset,
gsapPos,
stamp,
);
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
const restoreOffset = () => {
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
el.removeAttribute("data-hf-drag-initial-offset-x");
el.removeAttribute("data-hf-drag-initial-offset-y");
};
const restoreOffset = () => restoreDragOffset(el, stamp);
if (anim.arcPath?.enabled) {
const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
@@ -182,6 +182,7 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
await expect(
tryGsapResizeIntercept(
selection,
// fallow-ignore-next-line code-duplication
{ width: 344, height: 344 },
[],
null,
@@ -573,6 +574,76 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
expect(commitMutation).not.toHaveBeenCalled();
});
describe("the first resize of a keyframed element under auto-record", () => {
const positionKeys = {
id: "#title-to-1-position",
targetSelector: "#title",
propertyGroup: "position",
method: "to",
properties: {},
keyframes: { keyframes: [{ percentage: 0, properties: { x: 300, y: 200 } }] },
position: 1,
resolvedStart: 1,
duration: 3,
} as unknown as GsapAnimation;
it("writes a size key at the playhead in one mutation and hands the draft size to GSAP", async () => {
usePlayerStore.setState({ currentTime: 2 });
const commitMutation = vi.fn();
const selection = titleSelection();
selection.element.setAttribute("data-hf-studio-box-size", "true");
// fallow-ignore-next-line code-duplication
const handled = await tryGsapResizeIntercept(
selection,
{ width: 424.2, height: 237 },
[positionKeys],
null,
commitMutation,
);
expect(handled).toEqual({ status: "persisted" });
expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
{
type: "add-with-keyframes",
targetSelector: "#title",
position: 2,
duration: 1,
keyframes: [{ percentage: 0, properties: { width: 424, height: 237 } }],
},
]);
expect(selection.element.hasAttribute("data-hf-studio-box-size")).toBe(false);
});
const fadeKeys = {
...positionKeys,
id: "#title-to-1-visual",
propertyGroup: "visual",
keyframes: { keyframes: [{ percentage: 0, properties: { opacity: 0 } }] },
} as unknown as GsapAnimation;
it.each([
["with auto-record off", positionKeys, false],
["on an element whose only keyframes fade it", fadeKeys, true],
])("hands the size to CSS %s", async (_, keyed, autoKeyframeEnabled) => {
usePlayerStore.setState({ autoKeyframeEnabled });
const commitMutation = vi.fn();
try {
const handled = await tryGsapResizeIntercept(
titleSelection(),
{ width: 424, height: 237 },
[keyed],
null,
commitMutation,
);
expect(handled).toEqual({ status: "element-size" });
expect(commitMutation).not.toHaveBeenCalled();
} finally {
usePlayerStore.setState({ autoKeyframeEnabled: true });
}
});
});
it.each([
["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],
@@ -5,14 +5,18 @@
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
* group-tween resolution used by the drag/resize/rotate intercepts.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { clearStudioBoxSize } from "../components/editor/manualEdits";
import {
STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
} from "../components/editor/manualEditsTypes";
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
import {
setElementGsapPosition,
setElementGsapScale,
setElementGsapSize,
} from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { readGsapProperty } from "./gsapRuntimeReaders";
import {
@@ -23,7 +27,11 @@ import {
findSizeSetAnimation,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { computeDraggedGsapPosition, restoreDragOffset } from "./draggedGsapPosition";
import {
computeDraggedGsapPosition,
restoreDragOffset,
type DragStamp,
} from "./draggedGsapPosition";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -39,9 +47,11 @@ import {
} from "./gsapEditOutcome";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
import { singleKeyTweenMutation } from "./useEnableKeyframes";
const SIZE_PROPS = new Set(["width", "height"]);
const POSITION_XY = new Set(["x", "y"]);
const MOVED_OR_SIZED = new Set(["x", "y", "xPercent", "yPercent", "width", "height"]);
/**
* The element's box before the resize draft ran, in CSS pixels.
@@ -88,6 +98,7 @@ export async function commitSizeAtPlayhead(
selection.element,
dragOffset,
readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
callbacks.stamp,
)
: null;
const written = await commitValueAtPlayhead(
@@ -102,12 +113,43 @@ export async function commitSizeAtPlayhead(
...preGestureBoxSize(selection.element),
...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
},
...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
...(anchor && { beforeReload: () => restoreDragOffset(selection.element, callbacks.stamp) }),
},
);
return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
}
/** A keyed size is GSAP's at every time; the gesture's draft, re-applied after each seek, would pin it there. */
function handOverDraftSize(
selection: DomEditSelection,
written: GsapEditOutcome,
size: Record<string, number>,
draw: <T>(run: () => T) => T,
): GsapEditOutcome {
const { width, height } = size;
if (written.status !== "persisted" || width == null || height == null) return written;
draw(() => {
clearStudioBoxSize(selection.element);
setElementGsapSize(selection.element, width, height);
});
return written;
}
/** Under auto-record, a keyframed element's first resize is a size key at the playhead, held at all times. */
function firstSizeKey(
selection: DomEditSelection,
size: { width: number; height: number },
resizeGroup: PropertyGroupName,
animations: GsapAnimation[],
): Record<string, unknown> | null {
const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
const keyframed = animations.some(
(a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
);
if (resizeGroup !== "size" || !autoKeyframeEnabled || !keyframed) return null;
return singleKeyTweenMutation(selection, size, currentTime);
}
/**
* Whether this tween already states scale as `scaleX`/`scaleY`.
*
@@ -136,6 +178,7 @@ export async function tryGsapResizeIntercept(
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
dragOffset?: { x: number; y: number },
draw: <T>(run: () => T) => T = (run) => run(),
stamp?: DragStamp,
): Promise<GsapEditOutcome> {
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -170,10 +213,16 @@ export async function tryGsapResizeIntercept(
size,
});
if (!anim || isInstantHold(anim)) {
const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
const scriptWritesSize = allKnownAnimations.some((a) =>
animationWritesAnyProperty(a, SIZE_PROPS),
);
if (!scriptWritesSize) return { status: "element-size" };
if (!scriptWritesSize) {
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
if (!firstKey) return { status: "element-size" };
await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
}
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
if (!sel) return { status: "blocked", reason: "no-selector" };
// A scale hold is not a size hold.
@@ -191,8 +240,7 @@ export async function tryGsapResizeIntercept(
? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
: findSizeSetAnimation(workingAnimations, sel, selection.element);
// Keyframe the size only when a real tween already animates it, as move and
// rotate do; a fade or a slide on the element gets a plain size.
// Keyframe the size when a real tween already animates it; a static size set stays a set.
if (resizeGroup === "size") {
const animatedTween = pickClosestToPlayhead(
workingAnimations.filter(
@@ -204,11 +252,19 @@ export async function tryGsapResizeIntercept(
);
if (animatedTween) {
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
return commitSizeAtPlayhead(selection, animatedTween, sized, iframe, dragOffset, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
const written = await commitSizeAtPlayhead(
selection,
animatedTween,
sized,
iframe,
dragOffset,
{
commitMutation,
fetchAnimations: fetchFallbackAnimations,
stamp,
},
);
return handOverDraftSize(selection, written, sized, draw);
}
}
@@ -333,8 +389,7 @@ export async function tryGsapResizeIntercept(
// it lands back on the drop point. The compensation only applies to a STATIC
// position (a `tl.set` hold or none) — a keyframed position path has no
// single anchor to preserve, so it keeps the plain center-scale behavior.
// The size route commits the same width/height channels the draft wrote, so
// it needs none of this.
// The size route hands its draft to GSAP itself (handOverDraftSize).
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
// approximate rather than corner-exact.
// fallow-ignore-next-line complexity
@@ -363,6 +418,7 @@ export async function tryGsapResizeIntercept(
selection.element,
{ x: 0, y: 0 },
gsapPos,
stamp,
);
const base = { x: baseGsapX, y: baseGsapY };
setElementGsapPosition(draftEl, base.x, base.y);
@@ -424,6 +480,7 @@ export async function tryGsapResizeIntercept(
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
stamp,
}),
);
return true;
@@ -432,6 +489,7 @@ export async function tryGsapResizeIntercept(
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
stamp,
});
return true;
};
@@ -453,9 +511,18 @@ export async function tryGsapResizeIntercept(
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
if (resizeGroup === "size")
return commitSizeAtPlayhead(selection, anim, resizeProps, iframe, dragOffset, callbacks);
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
if (resizeGroup === "size") {
const written = await commitSizeAtPlayhead(
selection,
anim,
resizeProps,
iframe,
dragOffset,
callbacks,
);
return handOverDraftSize(selection, written, resizeProps, draw);
}
const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
label: "Resize",
backfill: resizeBackfill,
@@ -31,6 +31,7 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import type { DragStamp } from "./draggedGsapPosition";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import {
findGsapPositionAnimation,
@@ -243,6 +244,7 @@ export async function tryGsapDragIntercept(
preflightOnly?: boolean;
preflightPassed?: boolean;
group?: boolean;
stamp?: DragStamp;
},
): Promise<GsapEditOutcome> {
if (!options?.preflightPassed) {
@@ -323,6 +325,7 @@ export async function tryGsapDragIntercept(
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
stamp: options?.stamp,
});
return { status: "persisted" };
}
@@ -352,7 +355,7 @@ export async function tryGsapDragIntercept(
}
}
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
if (route === "whole-path") {
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
} else {
@@ -0,0 +1,44 @@
import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
import { gsap } from "gsap";
import { describe, expect, it } from "vitest";
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
// What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
const saved = {
position:
`tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
`tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
size:
`tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
`tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
};
function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
const target: Record<string, number> = { x: 0, width: 240 };
const run = new Function(
"gsap",
"target",
`${script.replaceAll('"#target"', "target")}\nreturn tl;`,
);
const tl = run(gsap, target) as gsap.core.Timeline;
for (const time of seeks) tl.seek(time);
return target[prop];
}
describe.each([
["position", "x", 47.754],
["size", "width", 340],
] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
it("plays and seeks to the same value once its tail is trimmed", () => {
const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
});
it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
const script = timeline + saved[name];
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
});
});
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
import {
beginStudioPendingEdit,
isStudioEditSaving,
setStudioPendingEditClaimClock,
trackStudioPendingEdit,
} from "../utils/studioPendingEdits";
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
});
});
it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
const { deps, actions } = mount(
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
PREDICTED,
);
let step!: () => void;
const stepped = new Promise<void>((resolve) => (step = resolve));
const undo = deps.editHistory.undo.getMockImplementation()!;
deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
const undone = actions.undo();
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
expect(isStudioEditSaving()).toBe(true);
step();
await act(() => undone);
await Promise.resolve();
expect(isStudioEditSaving()).toBe(false);
});
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
const { deps, actions } = mount(
{
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
import type { RestoreFiles } from "../utils/gsapUndoRestore";
import {
beginStudioShownRestore,
hasStudioPendingEdits,
paintBackNewestStudioPendingEdit,
} from "../utils/studioPendingEdits";
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
const shownLanded = predictedShown ? beginStudioShownRestore() : null;
const putBack = predictedShown ?? pendingEditShown?.showAgain;
const claimedAfter =
pendingEditShown?.claimsAtBegin ??
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
: stepped && editClaimed;
} finally {
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
shownLanded?.();
}
if (!result.ok && result.reason === "content-mismatch") {
showToast(
+32 -37
View File
@@ -31,6 +31,7 @@ import {
import { POSITION_PROPS } from "./gsapRuntimeReaders";
import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
import { roundTo3 } from "../utils/rounding";
import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import {
buildExtendedKeyframes,
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
}
export function singleKeyTweenMutation(
sel: DomEditSelection,
properties: Record<string, number>,
currentTime: number,
): Record<string, unknown> | null {
// A brand-new tween: author it against the one element the user selected.
// The bare class selectorFromSelection hands back for an id-less element
// animates every sibling sharing the class (see writeTargetSelector).
const targetSelector = writeTargetSelector(sel);
if (!targetSelector) return null;
const { start, duration } = resolveNewTweenRange(
sel.dataAttributes?.start,
sel.dataAttributes?.duration,
currentTime,
);
// One keyframe at the playhead, a single diamond capturing the current value;
// motion comes from the keyframes the user adds or drags later.
return {
type: "add-with-keyframes",
targetSelector,
position: roundTo3(start),
duration: roundTo3(duration),
keyframes: [{ percentage: 0, properties }],
};
}
// Authoritative parse of the current source for `sel`. Returns `null` when the
// fetch can't run (no projectId / request failed) so callers can distinguish
// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
}
} else {
const position = readElementPosition(iframe, sel, null);
const { start: elStart, duration: elDuration } = resolveNewTweenRange(
sel.dataAttributes?.start,
sel.dataAttributes?.duration,
t,
);
// A brand-new tween: author it against the one element the user selected.
// The bare class selectorFromSelection hands back for an id-less element
// animates every sibling sharing the class (see writeTargetSelector).
const selector = writeTargetSelector(sel);
if (!selector) {
session.handleGsapAddAnimation("to");
return;
}
if (Object.keys(position).length === 0) {
position.x = 0;
position.y = 0;
}
// One keyframe at the playhead — a single diamond capturing the current
// value. Motion comes from the user adding/dragging more keyframes later;
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
[{ percentage: 0, properties: { ...position } }];
if (session.commitMutation) {
await session.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
position: roundTo3(elStart),
duration: roundTo3(elDuration),
keyframes,
},
{ label: "Enable keyframes", softReload: true },
);
if (Object.keys(position).length === 0)
Object.assign(position, readTranslatePxLeavingPercent(sel.element));
const mutation = singleKeyTweenMutation(sel, position, t);
if (mutation && session.commitMutation) {
await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
} else {
session.handleGsapAddAnimation("to");
}
@@ -424,6 +424,7 @@ describe("useGsapAwareEditing anchored resize", () => {
expect.any(Function),
undefined,
expect.any(Function),
expect.objectContaining({ frozen: true }),
);
act(() => h.root.unmount());
});
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
import { selectorFromSelection } from "./gsapShared";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
restore: () => void = () => undefined,
route?: { plainTranslate: boolean },
) => {
const stamp = freezeDragStamp(selection.element);
const writes = observeGsapGesture(gsapCommitMutation);
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
previewIframeRef.current,
commitMutation,
makeFetchFallback(selection),
{ stamp },
);
// Saved after the size, under its undo key, so the two are one step.
await saveMove(dragOutcome, async () => {
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
x: 0,
y: 0,
};
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
const { newX, newY } = computeDraggedGsapPosition(
selection.element,
offset,
gsapPos,
stamp,
);
logResize("sync-settle", { gsapPos, offset, newX, newY });
setElementGsapPosition(selection.element, newX, newY);
});
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
makeFetchFallback(selection),
offset,
writes.drawKeepingUndone,
stamp,
);
assertGsapEditPersisted(outcome);
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
+6 -5
View File
@@ -8,7 +8,6 @@
type ElementGsapWindow = Window & {
gsap?: {
set?: (target: Element, vars: Record<string, number>) => void;
getProperty?: (target: Element, prop: string) => unknown;
};
};
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
return true;
}
/** The element's GSAP numeric property, or null when unreadable. */
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
return Number.isFinite(value) ? value : null;
/** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
const gsap = gsapOf(element);
if (!gsap?.set) return false;
gsap.set(element, { width, height });
return true;
}
/** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
@@ -21,6 +21,7 @@ import {
} from "./gsapUndoRestore";
import { applyPatch } from "./sourcePatcher";
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
@@ -281,6 +282,16 @@ describe("applyUndoRestoreToPreview", () => {
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
});
it("counts a script-only restore applied in place, so a reload requested before it loads again", () => {
const script = (x: number) => `window.__timelines["root"]=gsap.timeline().to("#a",{x:${x}});`;
const page = (x: number) => `<div id="a">t</div><script>${script(x)}</script>`;
const { iframe, doc } = buildLiveIframe(page(2));
const before = studioManualEditSavesIn(doc);
const files = { [ROOT]: { previous: wrap(page(2)), restored: wrap(page(1)) } };
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
});
it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
// The live doc holds a script element; a re-run would mutate/remove it
// (applySoftReload removes stale script elements before re-running).
@@ -563,6 +574,17 @@ describe("an undo that lands while the layer is being dragged", () => {
expect(el.style.getPropertyValue("width")).toBe("100px");
});
it("counts an undo shown or applied in place, so a reload requested before it loads again", () => {
const { iframe, doc } = buildLiveIframe(undone);
const before = studioManualEditSavesIn(doc);
showRestoreInPlace(iframe, ROOT, files, 3);
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
const shown = studioManualEditSavesIn(doc);
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(shown);
});
it("keeps a drag that started after the undo was shown when the undo is put back", () => {
const { iframe, doc } = buildLiveIframe(undone);
const el = doc.getElementById("a")!;
+4 -2
View File
@@ -14,7 +14,7 @@ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
import { markScenesStale } from "../player/sceneSwap";
import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
import { studioGestureDraws } from "../components/editor/manualEditsDom";
import { countStudioPreviewChange, studioGestureDraws } from "../components/editor/manualEditsDom";
type PreviewWindow = Window & {
__player?: { seek?: (t: number) => void };
@@ -312,7 +312,8 @@ export function showRestoreInPlace(
const doc = iframe?.contentDocument;
const win = iframe?.contentWindow as PreviewWindow | null;
const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
if (!iframe || !win || !plan || plan.scripted) return null;
if (!iframe || !doc || !win || !plan || plan.scripted) return null;
countStudioPreviewChange(doc);
const before = plan.targets.map(
({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
);
@@ -393,6 +394,7 @@ export function applyUndoRestoreToPreview(
reloadPreview();
return "full";
}
countStudioPreviewChange(doc);
// Sync each changed element's attributes onto the live DOM from the restored
// markup, so the runtime's seek-reapply reads the reverted values.
for (const target of plan.targets)
@@ -66,8 +66,18 @@ export function hasStudioPendingEdits(): boolean {
return pendingEdits.size > 0 || focusedField() !== null;
}
const shownRestores = new Set<Promise<void>>();
export function beginStudioShownRestore(): () => void {
let end = () => {};
const landed = new Promise<void>((resolve) => (end = resolve));
shownRestores.add(landed);
void landed.then(() => shownRestores.delete(landed));
return end;
}
export function isStudioEditSaving(): boolean {
return pendingEdits.size > 0;
return pendingEdits.size > 0 || shownRestores.size > 0;
}
export function afterStudioPendingEdits(run: () => void): () => void {
@@ -75,7 +85,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
const check = () => {
if (!waiting) return;
if (isStudioEditSaving()) {
void Promise.allSettled([...pendingEdits.keys()]).then(check);
void Promise.allSettled([...pendingEdits.keys(), ...shownRestores]).then(check);
return;
}
waiting = false;
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
// Drag paths (one long move) and sequences (several edits, no settle between), gsap none.
// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects them.
// Drag paths (one long move) and sequences (several edits, no settle between), gsap none but for GSAP_SEQUENCES.
// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects the plain ones.
const MOVE = { do: "drag", gesture: "move", by: [90, 60] };
const BACK = { do: "drag", gesture: "move", by: [-70, 50] };
@@ -26,16 +26,39 @@ const SEQUENCES = {
],
};
// A plain element animated the way a person does it: auto-record on, a keyframe added at the playhead (1 s),
// then edits at a later keyframe (3 s) and between (2 s); seeks back check each keyframe kept its box.
// Upward moves keep the grown, rotated box inside the nested frame, which clips it in preview and render alike.
const PLAIN_TO_KEYS = [
{ do: "drag", gesture: "move", by: [90, -40] },
{ do: "autokey" },
{ do: "addkey" },
{ do: "seek", time: 3 },
BACK,
{ do: "drag", gesture: "resize" },
{ do: "seek", time: 2 },
{ do: "drag", gesture: "resize" },
{ do: "drag", gesture: "move", by: [60, -30] },
{ do: "seek", time: 3 },
{ do: "seek", time: 1 },
];
/** On a GSAP-tweened box: a resize, then a drag (with or without an undo between) must save where it is let go. */
const GSAP_SEQUENCES = {
resizeundodrag: [{ do: "drag", gesture: "resize" }, { do: "undo" }, MOVE],
resizedrag: [{ do: "drag", gesture: "resize" }, MOVE],
};
/** Text in place: a double press opens it, Enter commits; `select` first double-clicks a word to replace. */
const TEXT = {
edit: [{ do: "text", word: "Teleport" }],
select: [{ do: "text", word: "Teleport", select: "accuracy" }],
};
const row = (gesture, c, steps) => ({
id: [gesture, "none", c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"),
const row = (gesture, c, steps, gsap = "none") => ({
id: [gesture, gsap, c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"),
gesture,
gsap: "none",
gsap,
...c,
steps,
other: Boolean(steps?.some((s) => s.element === "B")),
@@ -63,6 +86,20 @@ export function dragCases() {
text: true,
})),
);
const plainToKeys = [0, 30].flatMap((rotation) =>
everyPlacement.map((c) => ({
...row("seqplainkeys", { ...c, rotation }, PLAIN_TO_KEYS),
settle: true,
keyRender: 3,
})),
);
// Settled: a resize on a tween saves its size and its anchor separately, still one undo.
const gsapSequences = Object.entries(GSAP_SEQUENCES).flatMap(([name, steps]) =>
["root", "nested"].map((nesting) => ({
...row(`seq${name}`, pxRoot({ nesting }), steps, "tween"),
settle: true,
})),
);
// Rotate on the common centring idiom, a transform rather than the translate property.
const centred = [0, 30].flatMap((rotation) =>
["root", "nested"].map((nesting) => ({
@@ -71,5 +108,5 @@ export function dragCases() {
other: false,
})),
);
return [...paths, ...sequences, ...texts, ...centred];
return [...paths, ...sequences, ...plainToKeys, ...gsapSequences, ...texts, ...centred];
}
@@ -1,7 +1,7 @@
/** Drag paths and edit sequences: several steps with no settle between, then the usual commit, undo and reload. */
import { watch } from "node:fs";
import { dirname, join } from "node:path";
import { COMPOSITION } from "./grid.mjs";
import { COMPOSITION, PLAYHEAD } from "./grid.mjs";
import { centre, dist, percentile, quadDistance } from "./geometry.mjs";
import {
VIEWPORT,
@@ -30,6 +30,11 @@ import { scoreTeleport, stopFrames } from "./teleport.mjs";
const STEP_PX = 28;
const STRAIGHT_STEPS = 20;
const UNDONE_QUIET_MS = 3000;
const AUTO_KEYFRAME = 'button[aria-label="Auto-record manual edits as keyframes"]';
const ADD_KEYFRAME = 'button[aria-label^="Add keyframe at playhead"]';
/** Every step but a seek or a toggle writes the file once. */
const saves = (step) => step.do !== "seek" && step.do !== "autokey";
/** `n` evenly spaced points from `a` (excluded) to `b` (included). */
const legN = (a, b, n) =>
@@ -178,12 +183,31 @@ async function driveStep(ctx, step, state) {
return { do: "nudge", count: step.count };
}
if (step.do === "text") return editText(ctx.A, step, state);
if (step.do === "autokey") {
const pressed = () =>
page.$eval(AUTO_KEYFRAME, (b) => b.getAttribute("aria-pressed") === "true");
if (!(await pressed())) await page.click(AUTO_KEYFRAME);
state.autokey = await pressed();
return { do: "autokey", on: state.autokey };
}
if (step.do === "addkey") {
// The toolbar's own button, pressed where a person would; the box must not move.
const pre = await measure(ctx.A);
await recording(page, true);
await page.click(ADD_KEYFRAME);
await nextFrame(page);
state.smooth.push(smoothness(await recording(page, false)));
state.depth += 1;
state.intended = pre.visible;
return { do: "addkey" };
}
if (step.do === "seek") {
const seek = await page.evaluate(
(time) => window.__editBench.call("studio_seek", { time }),
step.time,
);
if (!seek?.ok) throw new Error(`studio_seek ${step.time} failed: ${JSON.stringify(seek)}`);
state.time = step.time;
return { do: "seek", time: step.time };
}
const name = step.element ?? "A";
@@ -281,6 +305,57 @@ async function editText(c, step, state) {
const round = (m) => m.visible.map((p) => p.map((v) => Math.round(v * 100) / 100));
/**
* A person's pace: wait for the step's save, then score the settled box against where the step put it
* (a seek: the box its keyframe time was left with, unless a size key since holds another size there).
*/
async function settleStep(c, steps, step, state, watcher) {
await untilSaved(watcher, steps.filter(saves).length);
if (step.do === "seek") state.intended = seekIntended(state);
const m = await settled(c);
const held = heldSize(state.sizeKeys, state.time);
Object.assign(steps.at(-1), {
saves: watcher.versions.length - 1,
box: boxError(state, m),
size: step.do === "seek" ? sizeError(held, m.size) : undefined,
});
rememberSizeKey(step, state, m);
rememberBox(step, state, m);
}
const seekIntended = (state) => {
const kept = state.keyBoxes.get(state.time);
return kept && !sizeError(kept.size, heldSize(state.sizeKeys, state.time)) ? kept.visible : null;
};
function rememberSizeKey(step, state, m) {
if (step.gesture === "resize" && state.autokey) state.sizeKeys.set(state.time, m.size);
}
function rememberBox(step, state, m) {
if (saves(step))
state.keyBoxes.set(state.time, { visible: state.intended ?? m.visible, size: m.size });
}
/** Under auto-record, size keys hold their nearest value before the first and after the last. */
export function heldSize(sizeKeys, time) {
const times = [...sizeKeys.keys()].sort((a, b) => a - b);
if (!times.length) return undefined;
return sizeKeys.get(Math.min(Math.max(time, times[0]), times.at(-1)));
}
const sizeError = (a, b) =>
a && b ? Math.max(Math.abs(a.width - b.width), Math.abs(a.height - b.height)) : 0;
async function untilSaved(watcher, owed) {
for (const deadline = Date.now() + 10_000; Date.now() < deadline; await sleep(50))
if (watcher.versions.length - 1 >= owed) return;
throw new Error(`save ${owed} never landed`);
}
const boxError = (state, m) =>
state.intended ? quadDistance(state.intended, m.visible) : undefined;
/** One path or sequence case, end to end, against a Studio already serving `dir`. */
export async function runSequence(args) {
const drags = args.spec.steps.some((s) => s.do === "drag");
@@ -310,6 +385,11 @@ async function measureSequence({ spec, dir, files, evidence }, session, control,
start: pre.visible,
intended: null,
text: null,
autokey: false,
sizeKeys: new Map(),
// Settled cases only: the box each keyframe time was left with, which a later seek there must show.
keyBoxes: new Map(),
time: spec.playhead ?? PLAYHEAD,
};
const steps = [];
// Back-to-back drags share one recording, so a drag's frames run up to the next press: a jump in
@@ -322,10 +402,11 @@ async function measureSequence({ spec, dir, files, evidence }, session, control,
for (const step of spec.steps) {
if (step.do !== "drag") await collect();
steps.push(await driveStep(ctx, step, state));
if (spec.settle) await settleStep(ctx.A, steps, step, state, watcher);
}
// Every step but a seek saves once (a nudge burst saves once); wait for all of them. An undo may
// Every saving step saves once (a nudge burst saves once); wait for all of them. An undo may
// cancel the save it follows, so one ending undone stops once the file has stayed original for 3 s.
const owed = spec.steps.filter((s) => s.do !== "seek").length;
const owed = spec.steps.filter(saves).length;
const endsUndone = spec.steps.at(-1).do === "undo" && state.depth === 0;
let backSince = null;
for (const deadline = Date.now() + 10_000; Date.now() < deadline; await sleep(50)) {
@@ -346,14 +427,17 @@ async function measureSequence({ spec, dir, files, evidence }, session, control,
const committedFiles = readFiles(dir, files);
evidence.files = committedFiles;
// The undo stack the steps imply: each save pushes (before, after), an undo pops.
// The undo stack the steps imply: each saving step pushes (before, after), an undo pops. A settled
// case knows where each step's saves ended, so one action is one undo even when it wrote twice.
const stack = [];
let vi = 0;
for (const s of spec.steps.filter((s) => s.do !== "seek")) {
vi += 1;
spec.steps.forEach((s, i) => {
if (!saves(s)) return;
const next = spec.settle ? steps[i].saves : vi + 1;
if (s.do === "undo") stack.pop();
else stack.push({ before: versions[vi - 1], after: versions[vi] });
}
else stack.push({ before: versions[vi], after: versions[next] });
vi = next;
});
const undo = await walk(ctx.A, "Control+z", stack.map((e) => e.before).reverse(), committedFiles);
// The file the steps should have left: the last surviving save's, or the original when all were undone.
const expected = stack.length ? stack.at(-1).after : versions[0];
@@ -392,6 +476,10 @@ async function measureSequence({ spec, dir, files, evidence }, session, control,
shown: shown?.includes(state.text.word) ?? false,
};
return {
...(spec.keyRender !== undefined &&
state.keyBoxes.has(spec.keyRender) && {
keyRender: { time: spec.keyRender, visible: state.keyBoxes.get(spec.keyRender).visible },
}),
zoom,
saved: versions.length > 1,
tracking: errors.length
@@ -399,8 +487,12 @@ async function measureSequence({ spec, dir, files, evidence }, session, control,
: { max: 0, p95: 0, frames: 0 },
pressJump: drags.length ? Math.max(...drags.map((s) => s.pressJump)) : null,
teleport: worst ? { ...worst.teleport, trace: undefined, step: steps.indexOf(worst) } : null,
// Against the box the last step left: its drag's last frame, the box after its keys, or the start when undone.
drop: quadDistance(intended, committed.visible),
// Against the box the last step left: its drag's last frame, the box after its keys, or the start when
// undone; a settled case also fails on any step whose settled box left where that step put it.
drop: Math.max(
quadDistance(intended, committed.visible),
...steps.map((s) => Math.max(s.box ?? 0, s.size ?? 0)),
),
reload: Math.max(
quadDistance(committed.visible, reloaded.visible),
quadDistance(intended, reloaded.visible),
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { swapPending, unsettledBy } from "./case.mjs";
import { heldSize } from "./sequences.mjs";
describe("settling on the shown preview", () => {
const read = (pending, x = 0, frames = "a") => ({
@@ -45,3 +46,18 @@ describe("settling on the shown preview", () => {
expect(await swapPending({ page: swapping, keys: undefined, selector: "#other" })).toBe(false);
});
});
describe("the size auto-record keys hold", () => {
const keys = new Map([
[3, { width: 440, height: 294 }],
[2, { width: 380, height: 250 }],
]);
it("holds the first key before it and the last after it, and leaves eased spans unscored", () => {
expect(heldSize(keys, 1)).toEqual({ width: 380, height: 250 });
expect(heldSize(keys, 3)).toEqual({ width: 440, height: 294 });
expect(heldSize(keys, 5)).toEqual({ width: 440, height: 294 });
expect(heldSize(keys, 2.5)).toBeUndefined();
expect(heldSize(new Map(), 1)).toBeUndefined();
});
});