mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user